diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 3655eb1b..ced3cd23 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.16.2", + "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", @@ -20,9 +21,12 @@ "helmet": "^7.1.0", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", + "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", + "node-cron": "^4.2.1", "nodemailer": "^6.9.7", + "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", "winston": "^3.11.0" @@ -40,6 +44,7 @@ "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", @@ -731,6 +736,273 @@ "node": ">=18.0.0" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", + "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", + "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.1.tgz", + "integrity": "sha512-UVZlVLfLyz6g3Hy7GNDpooMQonUygH7ghdiSASOOHy97fKj/mPLqgDX7aidOijn+sCMU+WU8NjlPlNTgnvbcGA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.12.0.tgz", + "integrity": "sha512-6vuh2R3Cte6SD6azNalLCjIDoryGdcvDVEV7IDRPtm5lHX5ffkDlIalaoOp5YJU08e4ipjJENel20kSMDLAcug==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz", + "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz", + "integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.0.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.23.0.tgz", + "integrity": "sha512-uHnfRwGAEHaYVXzpCtYsruy6PQxL2v76+MJ3+n/c/3PaTiTIa5ch7VofTUNoA39nHyjJbdiqTwFZK40OOTOkjw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.12.0.tgz", + "integrity": "sha512-4ucXbjVw8KJ5QBgnGJUeA07c8iznwlk5ioHIhI4ASXcXgcf2yRFhWzYOyWg/cI49LC9ekpFJeQtO3zjDTbl6TQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.7.4.tgz", + "integrity": "sha512-fjqvhrThwzzPvqhFOdkkGRJCHPQZTNijpceVy8QjcfQuH482tOVEjHyamZaioOhVtx+FK1u+eMpJA2Zz4U9LVg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1903,6 +2175,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-joda/core": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.6.5.tgz", + "integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==", + "license": "BSD-3-Clause" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2793,6 +3071,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@tediousjs/connection-string": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", + "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -3073,6 +3357,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mssql": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz", + "integrity": "sha512-mt9h5jWj+DYE5jxnKaWSV/GqDf9FV52XYVk6T3XZF69noEe+JJV6MKirii48l81+cjmAkSq+qeKX+k61fHkYrQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tarn": "^3.0.1", + "tedious": "*" + } + }, "node_modules/@types/multer": { "version": "1.4.13", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", @@ -3087,7 +3382,6 @@ "version": "20.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3111,6 +3405,16 @@ "@types/node": "*" } }, + "node_modules/@types/oracledb": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.9.1.tgz", + "integrity": "sha512-rXDnApyfaki0dvHuqzQvfirK6yHbtEO5nJ4CXKHrZYdwNAx4PjddqoCXdN1dZaEnZxXFwCy9xEWyIemL8EI/NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", @@ -3137,6 +3441,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.21.tgz", + "integrity": "sha512-19eKVv9tugr03IgfXlA9UVUVRbW6IuqRO5B92Dl4a6pT7K8uaGrNS0GkxiZD0BOk6PLuXl5FhWl//eX/pzYdTQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sanitize-html": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz", @@ -3436,6 +3749,20 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz", + "integrity": "sha512-SnbaqayTVFEA6/tYumdF0UmybY0KHyKwGPBXnyckFlrrKdhWFrL3a2HIPXHjht5ZOElKGcXfD2D63P36btb+ww==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3443,6 +3770,18 @@ "dev": true, "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3501,6 +3840,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3795,6 +4143,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -3814,6 +4182,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.3.tgz", + "integrity": "sha512-nHB8B5roHlGX5TFsWeiQJijdddZIOHuv1eL2cM2kHnG3qR91CYLsysGe+CvxQfEd23EKD0eJf4lto0frTbddKA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3939,6 +4344,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3951,6 +4380,21 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4316,6 +4760,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4527,7 +4980,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4583,6 +5035,46 @@ "node": ">=16.0.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -5159,6 +5651,24 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5984,6 +6494,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6006,6 +6542,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6133,6 +6689,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6176,6 +6747,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6214,6 +6803,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6904,6 +7508,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7393,6 +8003,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mssql": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz", + "integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.5.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "rfdc": "^1.3.0", + "tarn": "^3.0.2", + "tedious": "^18.2.1" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/multer": { "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", @@ -7469,6 +8099,12 @@ "node": ">=12" } }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7492,6 +8128,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -7725,6 +8370,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7743,6 +8406,16 @@ "node": ">= 0.8.0" } }, + "node_modules/oracledb": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.9.0.tgz", + "integrity": "sha512-NwPbIGPv6m0GTFSbyy4/5WEjsKMiiJRxztLmYUcfD3oyh/uXdmVmKOwEWr84wFwWJ/0wQrYQh4PjnzvShibRaA==", + "hasInstallScript": true, + "license": "(Apache-2.0 OR UPL-1.0)", + "engines": { + "node": ">=14.17" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8212,6 +8885,15 @@ } } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8499,6 +9181,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -8516,6 +9204,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9083,6 +9783,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.1.tgz", + "integrity": "sha512-9AvErXXQTd6l7TDd5EmM+nxbOGyhnmdbp/8c3pw+tjaiSXW9usME90ET/CRG1LN1Y9tPMtz/p83z4Q97B4DDpw==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.1", + "@types/node": ">=18", + "bl": "^6.0.11", + "iconv-lite": "^0.6.3", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tedious/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9317,7 +10065,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -9414,7 +10161,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -9673,6 +10419,21 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 36db7314..2caf0d1c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -28,6 +28,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.16.2", + "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", @@ -38,9 +39,12 @@ "helmet": "^7.1.0", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", + "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", + "node-cron": "^4.2.1", "nodemailer": "^6.9.7", + "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", "winston": "^3.11.0" @@ -58,6 +62,7 @@ "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.2", diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 846b4452..ee964c70 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -27,96 +27,121 @@ model external_call_configs { api_type String? @db.VarChar(20) config_data Json description String? - company_code String @default("*") @db.VarChar(20) is_active String? @default("Y") @db.Char(1) - created_date DateTime? @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) +} - @@index([is_active], map: "idx_external_call_configs_active") - @@index([company_code], map: "idx_external_call_configs_company") - @@index([call_type, api_type], map: "idx_external_call_configs_type") +model db_type_categories { + type_code String @id @db.VarChar(20) + display_name String @db.VarChar(50) + icon String? @db.VarChar(50) + color String? @db.VarChar(20) + sort_order Int? @default(0) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamp(6) + updated_at DateTime @default(now()) @updatedAt @db.Timestamp(6) } model external_db_connections { - id Int @id @default(autoincrement()) - connection_name String @db.VarChar(100) + id Int @id @default(autoincrement()) + connection_name String @db.VarChar(100) description String? - db_type String @db.VarChar(20) - host String @db.VarChar(255) + db_type String @db.VarChar(20) + host String @db.VarChar(255) port Int - database_name String @db.VarChar(100) - username String @db.VarChar(100) + database_name String @db.VarChar(100) + username String @db.VarChar(100) password String - connection_timeout Int? @default(30) - query_timeout Int? @default(60) - max_connections Int? @default(10) - ssl_enabled String? @default("N") @db.Char(1) - ssl_cert_path String? @db.VarChar(500) + connection_timeout Int? @default(30) + query_timeout Int? @default(60) + max_connections Int? @default(10) + ssl_enabled String? @default("N") @db.Char(1) + ssl_cert_path String? @db.VarChar(500) connection_options Json? - company_code String? @default("*") @db.VarChar(20) - is_active String? @default("Y") @db.Char(1) - created_date DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) - updated_by String? @db.VarChar(50) + company_code String? @default("*") @db.VarChar(20) + is_active String? @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + collection_configs data_collection_configs[] @@index([connection_name], map: "idx_external_db_connections_name") + @@index([db_type], map: "idx_external_db_connections_db_type") } -// 배치관리 테이블들 model batch_configs { - id Int @id @default(autoincrement()) - batch_name String @db.VarChar(100) - description String? - cron_schedule String @db.VarChar(50) // 크론탭 형식 - is_active String? @default("Y") @db.Char(1) - company_code String? @default("*") @db.VarChar(20) - created_date DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) - updated_by String? @db.VarChar(50) - + id Int @id @default(autoincrement()) + batch_name String @db.VarChar(100) + description String? + cron_schedule String @db.VarChar(50) + is_active String? @default("Y") @db.Char(1) + company_code String? @default("*") @db.VarChar(20) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + // 관계 설정 batch_mappings batch_mappings[] - + execution_logs batch_execution_logs[] + @@index([batch_name], map: "idx_batch_configs_name") @@index([is_active], map: "idx_batch_configs_active") } model batch_mappings { - id Int @id @default(autoincrement()) - batch_config_id Int - - // FROM 정보 - from_connection_type String @db.VarChar(20) // 'internal' 또는 'external' - from_connection_id Int? // external_db_connections.id (외부 DB인 경우) - from_table_name String @db.VarChar(100) - from_column_name String @db.VarChar(100) - from_column_type String? @db.VarChar(50) - - // TO 정보 - to_connection_type String @db.VarChar(20) // 'internal' 또는 'external' - to_connection_id Int? // external_db_connections.id (외부 DB인 경우) - to_table_name String @db.VarChar(100) - to_column_name String @db.VarChar(100) - to_column_type String? @db.VarChar(50) - - // 매핑 순서 (같은 FROM 컬럼에서 여러 TO로 매핑될 때 순서) - mapping_order Int? @default(1) - - created_date DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - + id Int @id @default(autoincrement()) + batch_config_id Int + from_connection_type String @db.VarChar(20) + from_connection_id Int? + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + from_column_type String? @db.VarChar(50) + to_connection_type String @db.VarChar(20) + to_connection_id Int? + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + to_column_type String? @db.VarChar(50) + mapping_order Int? @default(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + // 관계 설정 - batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade) - + batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade) + @@index([batch_config_id], map: "idx_batch_mappings_config") @@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from") @@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to") } +model batch_execution_logs { + id Int @id @default(autoincrement()) + batch_config_id Int + execution_status String @db.VarChar(20) + start_time DateTime @default(now()) @db.Timestamp(6) + end_time DateTime? @db.Timestamp(6) + duration_ms Int? + total_records Int? @default(0) + success_records Int? @default(0) + failed_records Int? @default(0) + error_message String? + error_details String? + server_name String? @db.VarChar(100) + process_id String? @db.VarChar(50) + + // 관계 설정 + batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade) + + @@index([batch_config_id], map: "idx_batch_execution_logs_config") + @@index([execution_status], map: "idx_batch_execution_logs_status") + @@index([start_time], map: "idx_batch_execution_logs_start_time") +} + model admin_supply_mng { objid Decimal @id @default(0) @db.Decimal supply_code String? @default("NULL::character varying") @db.VarChar(100) @@ -4020,9 +4045,6 @@ model table_relationships { updated_date DateTime? @db.Timestamp(6) updated_by String? @db.VarChar(50) diagram_id Int? - - // 역방향 관계 - bridges data_relationship_bridge[] } model data_relationship_bridge { @@ -4045,9 +4067,6 @@ model data_relationship_bridge { to_key_value String? @db.VarChar(500) to_record_id String? @db.VarChar(100) - // 관계 정의 - relationship table_relationships? @relation(fields: [relationship_id], references: [relationship_id]) - @@index([connection_type], map: "idx_data_bridge_connection_type") @@index([company_code, is_active], map: "idx_data_bridge_company_active") } @@ -4140,55 +4159,419 @@ model table_relationships_backup { } model test_sales_info { - sales_no String @id @db.VarChar(20) - contract_type String? @db.VarChar(50) - order_seq Int? - domestic_foreign String? @db.VarChar(20) - customer_name String? @db.VarChar(200) - product_type String? @db.VarChar(100) - machine_type String? @db.VarChar(100) - customer_project_name String? @db.VarChar(200) - expected_delivery_date DateTime? @db.Date - receiving_location String? @db.VarChar(200) - setup_location String? @db.VarChar(200) - equipment_direction String? @db.VarChar(100) - equipment_count Int? @default(0) - equipment_type String? @db.VarChar(100) - equipment_length Decimal? @db.Decimal(10,2) - manager_name String? @db.VarChar(100) - reg_date DateTime? @default(now()) @db.Timestamp(6) - status String? @default("진행중") @db.VarChar(50) - - // 관계 정의: 영업 정보에서 프로젝트로 - projects test_project_info[] + sales_no String @id(map: "pk_test_sales_info") @db.VarChar(200) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + product_type String? @db.VarChar(100) + machine_type String? @db.VarChar(100) + customer_project_name String? @db.VarChar(200) + expected_delivery_date DateTime? @db.Date + receiving_location String? @db.VarChar(200) + setup_location String? @db.VarChar(200) + equipment_direction String? @db.VarChar(100) + equipment_count Int? @default(0) + equipment_type String? @db.VarChar(100) + equipment_length Decimal? @db.Decimal(10, 2) + manager_name String? @db.VarChar(100) + reg_date DateTime? @default(now()) @db.Timestamp(6) + status String? @default("진행중") @db.VarChar(50) } model test_project_info { - project_no String @id @db.VarChar(200) - sales_no String? @db.VarChar(20) - contract_type String? @db.VarChar(50) - order_seq Int? - domestic_foreign String? @db.VarChar(20) - customer_name String? @db.VarChar(200) - - // 프로젝트 전용 컬럼들 - project_status String? @default("PLANNING") @db.VarChar(50) - project_start_date DateTime? @db.Date - project_end_date DateTime? @db.Date - project_manager String? @db.VarChar(100) - project_description String? @db.Text - - // 시스템 관리 컬럼들 - created_by String? @db.VarChar(100) - created_date DateTime? @default(now()) @db.Timestamp(6) - updated_by String? @db.VarChar(100) - updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) - - // 관계 정의: 영업 정보 참조 - sales test_sales_info? @relation(fields: [sales_no], references: [sales_no]) - + project_no String @id @db.VarChar(200) + sales_no String? @db.VarChar(20) + contract_type String? @db.VarChar(50) + order_seq Int? + domestic_foreign String? @db.VarChar(20) + customer_name String? @db.VarChar(200) + project_status String? @default("PLANNING") @db.VarChar(50) + project_start_date DateTime? @db.Date + project_end_date DateTime? @db.Date + project_manager String? @db.VarChar(100) + project_description String? + created_by String? @db.VarChar(100) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(100) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + @@index([sales_no], map: "idx_project_sales_no") @@index([project_status], map: "idx_project_status") @@index([customer_name], map: "idx_project_customer") @@index([project_manager], map: "idx_project_manager") } + +model batch_jobs { + id Int @id @default(autoincrement()) + job_name String @db.VarChar(100) + job_type String @db.VarChar(20) + description String? + created_by String? @db.VarChar(50) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + config_json Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + execution_count Int @default(0) + failure_count Int @default(0) + last_executed_at DateTime? @db.Timestamp(6) + next_execution_at DateTime? @db.Timestamp(6) + schedule_cron String? @db.VarChar(100) + success_count Int @default(0) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + is_active String @default("Y") @db.Char(1) + + @@index([job_type], map: "idx_batch_jobs_type") + @@index([company_code], map: "idx_batch_jobs_company_code") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model batch_job_executions { + id Int @id @default(autoincrement()) + job_id Int + execution_id String @unique @db.VarChar(100) + start_time DateTime @db.Timestamp(6) + end_time DateTime? @db.Timestamp(6) + status String @default("STARTED") @db.VarChar(20) + exit_code Int? + exit_message String? + parameters Json? + logs String? + created_at DateTime? @default(now()) @db.Timestamp(6) + + @@index([execution_id], map: "idx_batch_executions_execution_id") + @@index([job_id], map: "idx_batch_executions_job_id") + @@index([start_time], map: "idx_batch_executions_start_time") + @@index([status], map: "idx_batch_executions_status") +} + +model batch_job_parameters { + id Int @id @default(autoincrement()) + job_id Int + parameter_name String @db.VarChar(100) + parameter_value String? + parameter_type String? @default("STRING") @db.VarChar(50) + is_required Boolean? @default(false) + description String? + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @db.Timestamp(6) + + @@unique([job_id, parameter_name]) + @@index([job_id], map: "idx_batch_parameters_job_id") +} + +model batch_schedules { + id Int @id @default(autoincrement()) + job_id Int + schedule_name String @db.VarChar(255) + cron_expression String @db.VarChar(100) + timezone String? @default("Asia/Seoul") @db.VarChar(50) + is_active Boolean? @default(true) + start_date DateTime? @db.Date + end_date DateTime? @db.Date + created_by String @db.VarChar(100) + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(100) + updated_at DateTime? @db.Timestamp(6) + + @@index([is_active], map: "idx_batch_schedules_active") + @@index([job_id], map: "idx_batch_schedules_job_id") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model dataflow_external_calls { + id Int @id @default(autoincrement()) + diagram_id Int + source_table String @db.VarChar(100) + trigger_condition Json + external_call_config_id Int + message_template String? + is_active String? @default("Y") @db.Char(1) + created_by Int? + updated_by Int? + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @db.Timestamp(6) +} + +model ddl_execution_log { + id Int @id @default(autoincrement()) + user_id String @db.VarChar(100) + company_code String @db.VarChar(50) + ddl_type String @db.VarChar(50) + table_name String @db.VarChar(100) + ddl_query String + success Boolean + error_message String? + executed_at DateTime? @default(now()) @db.Timestamp(6) +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model external_call_logs { + id Int @id @default(autoincrement()) + dataflow_external_call_id Int? + external_call_config_id Int + trigger_data Json? + request_data Json? + response_data Json? + status String @db.VarChar(20) + error_message String? + execution_time Int? + executed_at DateTime? @default(now()) @db.Timestamp(6) + + @@index([executed_at], map: "idx_external_call_logs_executed") +} + +model my_custom_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + customer_name String? @db.VarChar + email_address String? @db.VarChar(255) +} + +model table_type_columns { + id Int @id @default(autoincrement()) + table_name String @db.VarChar(255) + column_name String @db.VarChar(255) + input_type String @default("text") @db.VarChar(50) + detail_settings String? @default("{}") + is_nullable String? @default("Y") @db.VarChar(10) + display_order Int? @default(0) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + + @@unique([table_name, column_name]) + @@index([input_type], map: "idx_table_type_columns_input_type") + @@index([table_name], map: "idx_table_type_columns_table_name") +} + +model test_api_integration_1758589777139 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + category String? @db.VarChar(500) +} + +model test_new_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + name String? @db.VarChar + email String? @db.VarChar(255) + user_test_column String? @db.VarChar + dsfsdf123215 String? @db.VarChar + aaaassda String? @db.VarChar +} + +model test_new_table33333 { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + writer String? @db.VarChar(100) + company_code String? @default("*") @db.VarChar(50) + eeeeeeee String? @db.VarChar(500) + wwww String? @db.VarChar(500) + sssss String? @db.VarChar(500) +} + +model test_new_table44444 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @db.VarChar(500) + ttttttt String? @db.VarChar(500) + yyyyyyy String? @db.VarChar(500) + uuuuuuu String? @db.VarChar(500) + iiiiiii String? @db.VarChar(500) +} + +model test_new_table555555 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @db.VarChar(500) + rtrtrtrtr String? @db.VarChar(500) + ererwewewe String? @db.VarChar(500) + wetyeryrtyut String? @db.VarChar(500) + werwqq String? @db.VarChar(500) + saved_file_name String? @db.VarChar(500) +} + +model test_table_info { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + objid Int + test_name String? @db.VarChar(250) + ggggggggggg String? @db.VarChar + test_column_1 String? @db.VarChar + test_column_2 String? @db.VarChar + test_column_3 String? @db.VarChar + final_test_column String? @db.VarChar + zzzzzzz String? @db.VarChar + bbbbbbb String? @db.VarChar + realtime_test String? @db.VarChar + table_update_test String? @db.VarChar +} + +model test_table_info2222 { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + company_code String? @default("*") @db.VarChar(50) + clll_cc String? @db.VarChar + eeee_eee String? @db.VarChar + saved_file_name String? @db.VarChar + debug_test_column String? @db.VarChar + field_1 String? @db.VarChar + rrrrrrrrrr String? @db.VarChar + tttttttt String? @db.VarChar +} + +model test_varchar_unified { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + launch_date String? @db.VarChar(500) + is_active String? @db.VarChar(500) +} + +model test_varchar_unified_1758588878993 { + id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500) + created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500) + writer String? @db.VarChar(500) + company_code String? @default("*") @db.VarChar(500) + product_name String? @db.VarChar(500) + price String? @db.VarChar(500) + launch_date String? @db.VarChar(500) + is_active String? @db.VarChar(500) +} + +model writer_test_table { + id Int @id @default(autoincrement()) + created_date DateTime? @default(now()) @db.Timestamp(6) + updated_date DateTime? @default(now()) @db.Timestamp(6) + writer String? @db.VarChar(100) + company_code String? @default("*") @db.VarChar(50) + test_field String? @db.VarChar + field_1 String? @db.VarChar +} + +model data_collection_configs { + id Int @id @default(autoincrement()) + config_name String @db.VarChar(100) + description String? + source_connection_id Int + source_table String @db.VarChar(100) + target_table String? @db.VarChar(100) + collection_type String @db.VarChar(20) + schedule_cron String? @db.VarChar(100) + is_active String @default("Y") @db.Char(1) + last_collected_at DateTime? @db.Timestamp(6) + collection_options Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + external_connection external_db_connections @relation(fields: [source_connection_id], references: [id]) + collection_history data_collection_history[] + collection_jobs data_collection_jobs[] + + @@index([source_connection_id], map: "idx_data_collection_configs_connection") + @@index([is_active], map: "idx_data_collection_configs_active") + @@index([company_code], map: "idx_data_collection_configs_company") +} + +model data_collection_jobs { + id Int @id @default(autoincrement()) + config_id Int + job_status String @db.VarChar(20) + started_at DateTime? @db.Timestamp(6) + completed_at DateTime? @db.Timestamp(6) + records_processed Int? @default(0) + error_message String? + job_details Json? + created_date DateTime? @default(now()) @db.Timestamp(6) + config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade) + + @@index([config_id], map: "idx_data_collection_jobs_config") + @@index([job_status], map: "idx_data_collection_jobs_status") + @@index([created_date], map: "idx_data_collection_jobs_created") +} + +model data_collection_history { + id Int @id @default(autoincrement()) + config_id Int + collection_date DateTime @db.Timestamp(6) + records_collected Int @default(0) + execution_time_ms Int @default(0) + status String @db.VarChar(20) + error_details String? + created_date DateTime? @default(now()) @db.Timestamp(6) + config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade) + + @@index([config_id], map: "idx_data_collection_history_config") + @@index([collection_date], map: "idx_data_collection_history_date") + @@index([status], map: "idx_data_collection_history_status") +} + +model collection_batch_management { + id Int @id @default(autoincrement()) + batch_name String @db.VarChar(100) + description String? + batch_type String @db.VarChar(20) + schedule_cron String? @db.VarChar(100) + is_active String @default("Y") @db.Char(1) + config_json Json? + last_executed_at DateTime? @db.Timestamp(6) + next_execution_at DateTime? @db.Timestamp(6) + execution_count Int @default(0) + success_count Int @default(0) + failure_count Int @default(0) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + company_code String @default("*") @db.VarChar(20) + batch_executions collection_batch_executions[] + + @@index([batch_type], map: "idx_collection_batch_mgmt_type") + @@index([is_active], map: "idx_collection_batch_mgmt_active") + @@index([company_code], map: "idx_collection_batch_mgmt_company") + @@index([next_execution_at], map: "idx_collection_batch_mgmt_next_execution") +} + +model collection_batch_executions { + id Int @id @default(autoincrement()) + batch_id Int + execution_status String @db.VarChar(20) + started_at DateTime? @db.Timestamp(6) + completed_at DateTime? @db.Timestamp(6) + execution_time_ms Int? + result_data Json? + error_message String? + log_details String? + created_date DateTime? @default(now()) @db.Timestamp(6) + batch collection_batch_management @relation(fields: [batch_id], references: [id], onDelete: Cascade) + + @@index([batch_id], map: "idx_collection_batch_executions_batch") + @@index([execution_status], map: "idx_collection_batch_executions_status") + @@index([created_date], map: "idx_collection_batch_executions_created") +} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 566b8849..fc8b3a03 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -32,8 +32,14 @@ import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; import batchRoutes from "./routes/batchRoutes"; +import batchManagementRoutes from "./routes/batchManagementRoutes"; +import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes"; +import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; import ddlRoutes from "./routes/ddlRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes"; +import { BatchSchedulerService } from "./services/batchSchedulerService"; +// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 +// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -129,8 +135,13 @@ app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); app.use("/api/batch-configs", batchRoutes); +app.use("/api/batch-management", batchManagementRoutes); +app.use("/api/batch-execution-logs", batchExecutionLogRoutes); +app.use("/api/db-type-categories", dbTypeCategoryRoutes); app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); +// app.use("/api/collections", collectionRoutes); // 임시 주석 +// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); @@ -150,11 +161,19 @@ app.use(errorHandler); const PORT = config.port; const HOST = config.host; -app.listen(PORT, HOST, () => { +app.listen(PORT, HOST, async () => { logger.info(`🚀 Server is running on ${HOST}:${PORT}`); logger.info(`📊 Environment: ${config.nodeEnv}`); logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + + // 배치 스케줄러 초기화 + try { + await BatchSchedulerService.initialize(); + logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); + } catch (error) { + logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); + } }); export default app; diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 93ad3b16..ba270f41 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -3,7 +3,7 @@ import { Request, Response } from "express"; import { BatchService } from "../services/batchService"; -import { BatchConfigFilter, BatchMappingRequest } from "../types/batchTypes"; +import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes"; export interface AuthenticatedRequest extends Request { user?: { @@ -20,172 +20,27 @@ export class BatchController { */ static async getBatchConfigs(req: AuthenticatedRequest, res: Response) { try { + const { page = 1, limit = 10, search, isActive } = req.query; + const filter: BatchConfigFilter = { - is_active: req.query.is_active as string, - company_code: req.query.company_code as string, - search: req.query.search as string, + page: Number(page), + limit: Number(limit), + search: search as string, + is_active: isActive as string }; - // 빈 값 제거 - Object.keys(filter).forEach((key) => { - if (!filter[key as keyof BatchConfigFilter]) { - delete filter[key as keyof BatchConfigFilter]; - } - }); - const result = await BatchService.getBatchConfigs(filter); - - if (result.success) { - return res.status(200).json(result); - } else { - return res.status(400).json(result); - } + + res.json({ + success: true, + data: result.data, + pagination: result.pagination + }); } catch (error) { console.error("배치 설정 목록 조회 오류:", error); - return res.status(500).json({ + res.status(500).json({ success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - /** - * 특정 배치 설정 조회 - * GET /api/batch-configs/:id - */ - static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { - try { - const id = parseInt(req.params.id); - - if (isNaN(id)) { - return res.status(400).json({ - success: false, - message: "유효하지 않은 배치 설정 ID입니다.", - }); - } - - const result = await BatchService.getBatchConfigById(id); - - if (result.success) { - return res.status(200).json(result); - } else { - return res.status(404).json(result); - } - } catch (error) { - console.error("배치 설정 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - /** - * 배치 설정 생성 - * POST /api/batch-configs - */ - static async createBatchConfig(req: AuthenticatedRequest, res: Response) { - try { - const data: BatchMappingRequest = req.body; - - // 필수 필드 검증 - if (!data.batch_name || !data.cron_schedule || !data.mappings) { - return res.status(400).json({ - success: false, - message: "필수 필드가 누락되었습니다. (batch_name, cron_schedule, mappings)", - }); - } - - const result = await BatchService.createBatchConfig( - data, - req.user?.userId - ); - - if (result.success) { - return res.status(201).json(result); - } else { - return res.status(400).json(result); - } - } catch (error) { - console.error("배치 설정 생성 오류:", error); - return res.status(500).json({ - success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - /** - * 배치 설정 수정 - * PUT /api/batch-configs/:id - */ - static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { - try { - const id = parseInt(req.params.id); - const data: Partial = req.body; - - if (isNaN(id)) { - return res.status(400).json({ - success: false, - message: "유효하지 않은 배치 설정 ID입니다.", - }); - } - - const result = await BatchService.updateBatchConfig( - id, - data, - req.user?.userId - ); - - if (result.success) { - return res.status(200).json(result); - } else { - return res.status(400).json(result); - } - } catch (error) { - console.error("배치 설정 수정 오류:", error); - return res.status(500).json({ - success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - /** - * 배치 설정 삭제 - * DELETE /api/batch-configs/:id - */ - static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) { - try { - const id = parseInt(req.params.id); - - if (isNaN(id)) { - return res.status(400).json({ - success: false, - message: "유효하지 않은 배치 설정 ID입니다.", - }); - } - - const result = await BatchService.deleteBatchConfig( - id, - req.user?.userId - ); - - if (result.success) { - return res.status(200).json(result); - } else { - return res.status(404).json(result); - } - } catch (error) { - console.error("배치 설정 삭제 오류:", error); - return res.status(500).json({ - success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: "배치 설정 목록 조회에 실패했습니다." }); } } @@ -197,116 +52,230 @@ export class BatchController { static async getAvailableConnections(req: AuthenticatedRequest, res: Response) { try { const result = await BatchService.getAvailableConnections(); - + if (result.success) { - return res.status(200).json(result); + res.json(result); } else { - return res.status(400).json(result); + res.status(500).json(result); } } catch (error) { console.error("커넥션 목록 조회 오류:", error); - return res.status(500).json({ + res.status(500).json({ success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: "커넥션 목록 조회에 실패했습니다." }); } } /** - * 특정 커넥션의 테이블 목록 조회 + * 테이블 목록 조회 (내부/외부 DB) * GET /api/batch-configs/connections/:type/tables * GET /api/batch-configs/connections/:type/:id/tables */ static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) { try { - const connectionType = req.params.type as 'internal' | 'external'; - const connectionId = req.params.id ? parseInt(req.params.id) : undefined; - - if (connectionType !== 'internal' && connectionType !== 'external') { + const { type, id } = req.params; + + if (!type || (type !== 'internal' && type !== 'external')) { return res.status(400).json({ success: false, - message: "유효하지 않은 커넥션 타입입니다. (internal 또는 external)", + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" }); } - if (connectionType === 'external' && (!connectionId || isNaN(connectionId))) { - return res.status(400).json({ - success: false, - message: "외부 커넥션의 경우 유효한 커넥션 ID가 필요합니다.", - }); - } - - const result = await BatchService.getTablesFromConnection( - connectionType, - connectionId - ); - + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchService.getTablesFromConnection(type, connectionId); + if (result.success) { - return res.status(200).json(result); + return res.json(result); } else { - return res.status(400).json(result); + return res.status(500).json(result); } } catch (error) { console.error("테이블 목록 조회 오류:", error); return res.status(500).json({ success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: "테이블 목록 조회에 실패했습니다." }); } } /** - * 특정 테이블의 컬럼 정보 조회 + * 테이블 컬럼 정보 조회 (내부/외부 DB) * GET /api/batch-configs/connections/:type/tables/:tableName/columns * GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns */ static async getTableColumns(req: AuthenticatedRequest, res: Response) { try { - const connectionType = req.params.type as 'internal' | 'external'; - const connectionId = req.params.id ? parseInt(req.params.id) : undefined; - const tableName = req.params.tableName; - - if (connectionType !== 'internal' && connectionType !== 'external') { + const { type, id, tableName } = req.params; + + if (!type || !tableName) { return res.status(400).json({ success: false, - message: "유효하지 않은 커넥션 타입입니다. (internal 또는 external)", + message: "연결 타입과 테이블명을 모두 지정해주세요." }); } - if (connectionType === 'external' && (!connectionId || isNaN(connectionId))) { + if (type !== 'internal' && type !== 'external') { return res.status(400).json({ success: false, - message: "외부 커넥션의 경우 유효한 커넥션 ID가 필요합니다.", + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" }); } - if (!tableName) { - return res.status(400).json({ - success: false, - message: "테이블명이 필요합니다.", - }); - } - - const result = await BatchService.getTableColumns( - connectionType, - tableName, - connectionId - ); - + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchService.getTableColumns(type, connectionId, tableName); + if (result.success) { - return res.status(200).json(result); + return res.json(result); } else { - return res.status(400).json(result); + return res.status(500).json(result); } } catch (error) { console.error("컬럼 정보 조회 오류:", error); return res.status(500).json({ success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: "컬럼 정보 조회에 실패했습니다." }); } } -} + + /** + * 특정 배치 설정 조회 + * GET /api/batch-configs/:id + */ + static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const batchConfig = await BatchService.getBatchConfigById(Number(id)); + + if (!batchConfig) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + return res.json({ + success: true, + data: batchConfig + }); + } catch (error) { + console.error("배치 설정 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 조회에 실패했습니다." + }); + } + } + + /** + * 배치 설정 생성 + * POST /api/batch-configs + */ + static async createBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { batchName, description, cronSchedule, mappings } = req.body; + + if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)" + }); + } + + const batchConfig = await BatchService.createBatchConfig({ + batchName, + description, + cronSchedule, + mappings + } as CreateBatchConfigRequest); + + return res.status(201).json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 생성되었습니다." + }); + } catch (error) { + console.error("배치 설정 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 생성에 실패했습니다." + }); + } + } + + /** + * 배치 설정 수정 + * PUT /api/batch-configs/:id + */ + static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const { batchName, description, cronSchedule, mappings, isActive } = req.body; + + if (!batchName || !cronSchedule) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)" + }); + } + + const batchConfig = await BatchService.updateBatchConfig(Number(id), { + batchName, + description, + cronSchedule, + mappings, + isActive + } as UpdateBatchConfigRequest); + + if (!batchConfig) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + return res.json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 수정되었습니다." + }); + } catch (error) { + console.error("배치 설정 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 수정에 실패했습니다." + }); + } + } + + /** + * 배치 설정 삭제 (논리 삭제) + * DELETE /api/batch-configs/:id + */ + static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const result = await BatchService.deleteBatchConfig(Number(id)); + + if (!result) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + return res.json({ + success: true, + message: "배치 설정이 성공적으로 삭제되었습니다." + }); + } catch (error) { + console.error("배치 설정 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 삭제에 실패했습니다." + }); + } + } +} \ No newline at end of file diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts new file mode 100644 index 00000000..e0fbd0ef --- /dev/null +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -0,0 +1,178 @@ +// 배치 실행 로그 컨트롤러 +// 작성일: 2024-12-24 + +import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { BatchExecutionLogService } from "../services/batchExecutionLogService"; +import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes"; + +export class BatchExecutionLogController { + /** + * 배치 실행 로그 목록 조회 + */ + static async getExecutionLogs(req: AuthenticatedRequest, res: Response) { + try { + const { + batch_config_id, + execution_status, + start_date, + end_date, + page, + limit + } = req.query; + + const filter: BatchExecutionLogFilter = { + batch_config_id: batch_config_id ? Number(batch_config_id) : undefined, + execution_status: execution_status as string, + start_date: start_date ? new Date(start_date as string) : undefined, + end_date: end_date ? new Date(end_date as string) : undefined, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined + }; + + const result = await BatchExecutionLogService.getExecutionLogs(filter); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const data: CreateBatchExecutionLogRequest = req.body; + + const result = await BatchExecutionLogService.createExecutionLog(data); + + if (result.success) { + res.status(201).json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 생성 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const data: UpdateBatchExecutionLogRequest = req.body; + + const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 업데이트 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 로그 삭제 + */ + static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + + const result = await BatchExecutionLogService.deleteExecutionLog(Number(id)); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 로그 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 로그 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 배치의 최신 실행 로그 조회 + */ + static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) { + try { + const { batchConfigId } = req.params; + + const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId)); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("최신 배치 실행 로그 조회 오류:", error); + res.status(500).json({ + success: false, + message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 실행 통계 조회 + */ + static async getExecutionStats(req: AuthenticatedRequest, res: Response) { + try { + const { + batch_config_id, + start_date, + end_date + } = req.query; + + const result = await BatchExecutionLogService.getExecutionStats( + batch_config_id ? Number(batch_config_id) : undefined, + start_date ? new Date(start_date as string) : undefined, + end_date ? new Date(end_date as string) : undefined + ); + + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("배치 실행 통계 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 실행 통계 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } +} diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts new file mode 100644 index 00000000..15de2e35 --- /dev/null +++ b/backend-node/src/controllers/batchManagementController.ts @@ -0,0 +1,345 @@ +// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService"; +import { BatchService } from "../services/batchService"; +import { BatchSchedulerService } from "../services/batchSchedulerService"; +import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; + +export class BatchManagementController { + /** + * 사용 가능한 커넥션 목록 조회 + */ + static async getAvailableConnections(req: AuthenticatedRequest, res: Response) { + try { + const result = await BatchManagementService.getAvailableConnections(); + if (result.success) { + res.json(result); + } else { + res.status(500).json(result); + } + } catch (error) { + console.error("커넥션 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "커넥션 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 커넥션의 테이블 목록 조회 + */ + static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) { + try { + const { type, id } = req.params; + + if (type !== 'internal' && type !== 'external') { + return res.status(400).json({ + success: false, + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" + }); + } + + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchManagementService.getTablesFromConnection(type, connectionId); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 특정 테이블의 컬럼 정보 조회 + */ + static async getTableColumns(req: AuthenticatedRequest, res: Response) { + try { + const { type, id, tableName } = req.params; + + if (type !== 'internal' && type !== 'external') { + return res.status(400).json({ + success: false, + message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)" + }); + } + + const connectionId = type === 'external' ? Number(id) : undefined; + const result = await BatchManagementService.getTableColumns(type, connectionId, tableName); + + if (result.success) { + return res.json(result); + } else { + return res.status(500).json(result); + } + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 생성 + * POST /api/batch-management/batch-configs + */ + static async createBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { batchName, description, cronSchedule, mappings, isActive } = req.body; + + if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)" + }); + } + + const batchConfig = await BatchService.createBatchConfig({ + batchName, + description, + cronSchedule, + mappings, + isActive: isActive !== undefined ? isActive : true + } as CreateBatchConfigRequest); + + return res.status(201).json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 생성되었습니다." + }); + } catch (error) { + console.error("배치 설정 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 생성에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 목록 조회 + * GET /api/batch-management/batch-configs + */ + static async getBatchConfigs(req: AuthenticatedRequest, res: Response) { + try { + const { page = 1, limit = 10, search, isActive } = req.query; + + const filter = { + page: Number(page), + limit: Number(limit), + search: search as string, + is_active: isActive as string + }; + + const result = await BatchService.getBatchConfigs(filter); + + res.json({ + success: true, + data: result.data, + pagination: result.pagination + }); + } catch (error) { + console.error("배치 설정 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "배치 설정 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 수동 실행 + * POST /api/batch-management/batch-configs/:id/execute + */ + static async executeBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + + if (!id || isNaN(Number(id))) { + return res.status(400).json({ + success: false, + message: "올바른 배치 설정 ID를 제공해주세요." + }); + } + + // 배치 설정 조회 + const batchConfigResult = await BatchService.getBatchConfigById(Number(id)); + if (!batchConfigResult.success || !batchConfigResult.data) { + return res.status(404).json({ + success: false, + message: "배치 설정을 찾을 수 없습니다." + }); + } + + const batchConfig = batchConfigResult.data as BatchConfig; + + // 배치 실행 로직 (간단한 버전) + const startTime = new Date(); + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + try { + console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`); + + // 실행 로그 생성 + const executionLog = await BatchService.createExecutionLog({ + batch_config_id: Number(id), + execution_status: 'RUNNING', + start_time: startTime, + total_records: 0, + success_records: 0, + failed_records: 0 + }); + + // 실제 배치 실행 (매핑이 있는 경우) + if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) { + // 테이블별로 매핑을 그룹화 + const tableGroups = new Map(); + + for (const mapping of batchConfig.batch_mappings) { + const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`; + if (!tableGroups.has(key)) { + tableGroups.set(key, []); + } + tableGroups.get(key)!.push(mapping); + } + + // 각 테이블 그룹별로 처리 + for (const [tableKey, mappings] of tableGroups) { + try { + const firstMapping = mappings[0]; + console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); + + // FROM 테이블에서 매핑된 컬럼들만 조회 + const fromColumns = mappings.map(m => m.from_column_name); + const fromData = await BatchService.getDataFromTableWithColumns( + firstMapping.from_table_name, + fromColumns, + firstMapping.from_connection_type as 'internal' | 'external', + firstMapping.from_connection_id || undefined + ); + totalRecords += fromData.length; + + // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + const mappedData = fromData.map(row => { + const mappedRow: any = {}; + for (const mapping of mappings) { + mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + } + return mappedRow; + }); + + // TO 테이블에 데이터 삽입 + const insertResult = await BatchService.insertDataToTable( + firstMapping.to_table_name, + mappedData, + firstMapping.to_connection_type as 'internal' | 'external', + firstMapping.to_connection_id || undefined + ); + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + console.error(`테이블 처리 실패: ${tableKey}`, error); + failedRecords += 1; + } + } + } else { + console.log("매핑이 없어서 데이터 처리를 건너뜁니다."); + } + + // 실행 로그 업데이트 (성공) + await BatchService.updateExecutionLog(executionLog.id, { + execution_status: 'SUCCESS', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + total_records: totalRecords, + success_records: successRecords, + failed_records: failedRecords + }); + + return res.json({ + success: true, + message: "배치가 성공적으로 실행되었습니다.", + data: { + batchId: id, + totalRecords, + successRecords, + failedRecords, + duration: Date.now() - startTime.getTime() + } + }); + } catch (error) { + console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error); + + return res.status(500).json({ + success: false, + message: "배치 실행에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } catch (error) { + console.error("배치 실행 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 실행 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + /** + * 배치 설정 업데이트 + * PUT /api/batch-management/batch-configs/:id + */ + static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const updateData = req.body; + + if (!id || isNaN(Number(id))) { + return res.status(400).json({ + success: false, + message: "올바른 배치 설정 ID를 제공해주세요." + }); + } + + const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData); + + // 스케줄러에서 배치 스케줄 업데이트 + await BatchSchedulerService.updateBatchSchedule(Number(id)); + + return res.json({ + success: true, + data: batchConfig, + message: "배치 설정이 성공적으로 업데이트되었습니다." + }); + } catch (error) { + console.error("배치 설정 업데이트 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 설정 업데이트에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } +} diff --git a/backend-node/src/database/MSSQLConnector.ts b/backend-node/src/database/MSSQLConnector.ts new file mode 100644 index 00000000..fc1c195c --- /dev/null +++ b/backend-node/src/database/MSSQLConnector.ts @@ -0,0 +1,183 @@ +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +// @ts-ignore +import * as mssql from 'mssql'; + +export class MSSQLConnector implements DatabaseConnector { + private pool: mssql.ConnectionPool | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (!this.pool) { + const config: mssql.config = { + server: this.config.host, + port: this.config.port, + user: this.config.user, + password: this.config.password, + database: this.config.database, + options: { + encrypt: this.config.ssl === true, + trustServerCertificate: true + }, + connectionTimeout: this.config.connectionTimeoutMillis || 15000, + requestTimeout: this.config.queryTimeoutMillis || 15000 + }; + this.pool = await new mssql.ConnectionPool(config).connect(); + } + } + + async disconnect(): Promise { + if (this.pool) { + await this.pool.close(); + this.pool = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + + // 버전 정보 조회 + const versionResult = await this.pool!.request().query('SELECT @@VERSION as version'); + + // 데이터베이스 크기 조회 + const sizeResult = await this.pool!.request() + .input('dbName', mssql.VarChar, this.config.database) + .query(` + SELECT SUM(size * 8 * 1024) as size + FROM sys.master_files + WHERE database_id = DB_ID(@dbName) + `); + + const responseTime = Date.now() - startTime; + + return { + success: true, + message: "MSSQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: versionResult.recordset[0]?.version || "알 수 없음", + database_size: this.formatBytes(parseInt(sizeResult.recordset[0]?.size || "0")), + }, + }; + } catch (error: any) { + return { + success: false, + message: "MSSQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const result = await this.pool!.request().query(query); + return { + rows: result.recordset, + rowCount: result.rowsAffected[0], + }; + } catch (error: any) { + throw new Error(`쿼리 실행 오류: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const result = await this.pool!.request().query(` + SELECT + t.TABLE_NAME as table_name, + c.COLUMN_NAME as column_name, + c.DATA_TYPE as data_type, + c.IS_NULLABLE as is_nullable, + c.COLUMN_DEFAULT as column_default, + CAST(p.value AS NVARCHAR(MAX)) as description + FROM INFORMATION_SCHEMA.TABLES t + LEFT JOIN INFORMATION_SCHEMA.COLUMNS c + ON c.TABLE_NAME = t.TABLE_NAME + LEFT JOIN sys.extended_properties p + ON p.major_id = OBJECT_ID(t.TABLE_NAME) + AND p.minor_id = 0 + AND p.name = 'MS_Description' + WHERE t.TABLE_TYPE = 'BASE TABLE' + ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION + `); + + // 결과를 TableInfo[] 형식으로 변환 + const tables = new Map(); + + result.recordset.forEach((row: any) => { + if (!tables.has(row.table_name)) { + tables.set(row.table_name, { + table_name: row.table_name, + columns: [], + description: row.description || null + }); + } + + if (row.column_name) { + tables.get(row.table_name)!.columns.push({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable === 'YES' ? 'Y' : 'N', + column_default: row.column_default + }); + } + }); + + return Array.from(tables.values()); + } catch (error: any) { + throw new Error(`테이블 목록 조회 오류: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + await this.connect(); + const result = await this.pool!.request() + .input('tableName', mssql.VarChar, tableName) + .query(` + SELECT + c.COLUMN_NAME as name, + c.DATA_TYPE as type, + c.IS_NULLABLE as nullable, + c.COLUMN_DEFAULT as default_value, + c.CHARACTER_MAXIMUM_LENGTH as max_length, + c.NUMERIC_PRECISION as precision, + c.NUMERIC_SCALE as scale, + CAST(p.value AS NVARCHAR(MAX)) as description + FROM INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN sys.columns sc + ON sc.object_id = OBJECT_ID(@tableName) + AND sc.name = c.COLUMN_NAME + LEFT JOIN sys.extended_properties p + ON p.major_id = sc.object_id + AND p.minor_id = sc.column_id + AND p.name = 'MS_Description' + WHERE c.TABLE_NAME = @tableName + ORDER BY c.ORDINAL_POSITION + `); + + return result.recordset; + } catch (error: any) { + throw new Error(`컬럼 정보 조회 오류: ${error.message}`); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} \ No newline at end of file diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts new file mode 100644 index 00000000..8167cacc --- /dev/null +++ b/backend-node/src/database/MariaDBConnector.ts @@ -0,0 +1,136 @@ +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +// @ts-ignore +import * as mysql from 'mysql2/promise'; + +export class MariaDBConnector implements DatabaseConnector { + private connection: mysql.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (!this.connection) { + this.connection = await mysql.createConnection({ + host: this.config.host, + port: this.config.port, + user: this.config.user, + password: this.config.password, + database: this.config.database, + connectTimeout: this.config.connectionTimeoutMillis, + ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + }); + } + } + + async disconnect(): Promise { + if (this.connection) { + await this.connection.end(); + this.connection = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const version = (rows as any[])[0]?.version || "Unknown"; + const responseTime = Date.now() - startTime; + await this.disconnect(); + return { + success: true, + message: "MariaDB/MySQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: version, + }, + }; + } catch (error: any) { + await this.disconnect(); + return { + success: false, + message: "MariaDB/MySQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const [rows, fields] = await this.connection!.query(query); + await this.disconnect(); + return { + rows: rows as any[], + fields: fields as any[], + }; + } catch (error: any) { + await this.disconnect(); + throw new Error(`쿼리 실행 실패: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const [rows] = await this.connection!.query(` + SELECT + TABLE_NAME as table_name, + TABLE_COMMENT as description + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME; + `); + + const tables: TableInfo[] = []; + for (const row of rows as any[]) { + const columns = await this.getColumns(row.table_name); + tables.push({ + table_name: row.table_name, + description: row.description || null, + columns: columns, + }); + } + await this.disconnect(); + return tables; + } catch (error: any) { + await this.disconnect(); + throw new Error(`테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); + await this.connect(); + console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); + + const [rows] = await this.connection!.query(` + SELECT + COLUMN_NAME as column_name, + DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, + COLUMN_DEFAULT as column_default + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION; + `, [tableName]); + + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); + console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); + + await this.disconnect(); + return rows as any[]; + } catch (error: any) { + console.error(`[MariaDBConnector] getColumns 오류:`, error); + await this.disconnect(); + throw new Error(`컬럼 정보 조회 실패: ${error.message}`); + } + } +} \ No newline at end of file diff --git a/backend-node/src/database/OracleConnector.ts b/backend-node/src/database/OracleConnector.ts new file mode 100644 index 00000000..e4d23128 --- /dev/null +++ b/backend-node/src/database/OracleConnector.ts @@ -0,0 +1,235 @@ +// @ts-ignore +import * as oracledb from 'oracledb'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class OracleConnector implements DatabaseConnector { + private connection: oracledb.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + + // Oracle XE 21c 특화 설정 + // oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT; + // oracledb.autoCommit = true; + } + + async connect(): Promise { + try { + // Oracle XE 21c 연결 문자열 구성 + const connectionString = this.buildConnectionString(); + + const connectionConfig: any = { + user: this.config.user, + password: this.config.password, + connectString: connectionString + }; + + this.connection = await oracledb.getConnection(connectionConfig); + console.log('Oracle XE 21c 연결 성공'); + } catch (error: any) { + console.error('Oracle XE 21c 연결 실패:', error); + throw new Error(`Oracle 연결 실패: ${error.message}`); + } + } + + private buildConnectionString(): string { + const { host, port, database } = this.config; + + // Oracle XE 21c는 기본적으로 XE 서비스명을 사용 + // 다양한 연결 문자열 형식 지원 + if (database.includes('/') || database.includes(':')) { + // 이미 완전한 연결 문자열인 경우 + return database; + } + + // Oracle XE 21c 표준 형식 + return `${host}:${port}/${database}`; + } + + async disconnect(): Promise { + if (this.connection) { + try { + await this.connection.close(); + this.connection = null; + console.log('Oracle 연결 해제됨'); + } catch (error: any) { + console.error('Oracle 연결 해제 실패:', error); + } + } + } + + async testConnection(): Promise { + try { + if (!this.connection) { + await this.connect(); + } + + // Oracle XE 21c 버전 확인 쿼리 + const result = await this.connection!.execute( + 'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\'' + ); + + console.log('Oracle 버전:', result.rows); + return { + success: true, + message: '연결 성공', + details: { + server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown' + } + }; + } catch (error: any) { + console.error('Oracle 연결 테스트 실패:', error); + return { + success: false, + message: '연결 실패', + details: { + server_version: error.message + } + }; + } + } + + async executeQuery(query: string, params: any[] = []): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const startTime = Date.now(); + + // Oracle XE 21c 쿼리 실행 옵션 + const options: any = { + outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format + maxRows: 10000, // XE 제한 고려 + fetchArraySize: 100 + }; + + const result = await this.connection!.execute(query, params, options); + const executionTime = Date.now() - startTime; + + console.log('Oracle 쿼리 실행 결과:', { + query, + rowCount: result.rows?.length || 0, + metaData: result.metaData?.length || 0, + executionTime: `${executionTime}ms`, + actualRows: result.rows, + metaDataInfo: result.metaData + }); + + return { + rows: result.rows || [], + rowCount: result.rowsAffected || (result.rows?.length || 0), + fields: this.extractFieldInfo(result.metaData || []) + }; + } catch (error: any) { + console.error('Oracle 쿼리 실행 실패:', error); + throw new Error(`쿼리 실행 실패: ${error.message}`); + } + } + + private extractFieldInfo(metaData: any[]): any[] { + return metaData.map(field => ({ + name: field.name, + type: this.mapOracleType(field.dbType), + length: field.precision || field.byteSize, + nullable: field.nullable + })); + } + + private mapOracleType(oracleType: any): string { + // Oracle XE 21c 타입 매핑 (간단한 방식) + if (typeof oracleType === 'string') { + return oracleType; + } + return 'UNKNOWN'; + } + + async getTables(): Promise { + try { + // 현재 사용자 스키마의 테이블들만 조회 + const query = ` + SELECT table_name, USER as owner + FROM user_tables + ORDER BY table_name + `; + + console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user); + + const result = await this.executeQuery(query); + console.log('사용자 스키마 테이블 조회 결과:', result.rows); + + const tables = result.rows.map((row: any) => ({ + table_name: row.TABLE_NAME, + columns: [], + description: null + })); + + console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`); + return tables; + + } catch (error: any) { + console.error('Oracle 테이블 목록 조회 실패:', error); + throw new Error(`테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`); + + const query = ` + SELECT + column_name, + data_type, + data_length, + data_precision, + data_scale, + nullable, + data_default + FROM user_tab_columns + WHERE table_name = UPPER(:tableName) + ORDER BY column_id + `; + + console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`); + const result = await this.executeQuery(query, [tableName]); + + console.log(`[OracleConnector] 쿼리 결과:`, result.rows); + console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined'); + + const mappedResult = result.rows.map((row: any) => ({ + column_name: row.COLUMN_NAME, + data_type: this.formatOracleDataType(row), + is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO', + column_default: row.DATA_DEFAULT + })); + + console.log(`[OracleConnector] 매핑된 결과:`, mappedResult); + return mappedResult; + } catch (error: any) { + console.error('[OracleConnector] getColumns 오류:', error); + throw new Error(`테이블 컬럼 조회 실패: ${error.message}`); + } + } + + private formatOracleDataType(row: any): string { + const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row; + + switch (DATA_TYPE) { + case 'NUMBER': + if (DATA_PRECISION && DATA_SCALE !== null) { + return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`; + } else if (DATA_PRECISION) { + return `NUMBER(${DATA_PRECISION})`; + } + return 'NUMBER'; + case 'VARCHAR2': + case 'CHAR': + return `${DATA_TYPE}(${DATA_LENGTH})`; + default: + return DATA_TYPE; + } + } +} diff --git a/backend-node/src/routes/batchExecutionLogRoutes.ts b/backend-node/src/routes/batchExecutionLogRoutes.ts new file mode 100644 index 00000000..cc30c513 --- /dev/null +++ b/backend-node/src/routes/batchExecutionLogRoutes.ts @@ -0,0 +1,46 @@ +// 배치 실행 로그 라우트 +// 작성일: 2024-12-24 + +import { Router } from "express"; +import { BatchExecutionLogController } from "../controllers/batchExecutionLogController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +/** + * GET /api/batch-execution-logs + * 배치 실행 로그 목록 조회 + */ +router.get("/", authenticateToken, BatchExecutionLogController.getExecutionLogs); + +/** + * POST /api/batch-execution-logs + * 배치 실행 로그 생성 + */ +router.post("/", authenticateToken, BatchExecutionLogController.createExecutionLog); + +/** + * PUT /api/batch-execution-logs/:id + * 배치 실행 로그 업데이트 + */ +router.put("/:id", authenticateToken, BatchExecutionLogController.updateExecutionLog); + +/** + * DELETE /api/batch-execution-logs/:id + * 배치 실행 로그 삭제 + */ +router.delete("/:id", authenticateToken, BatchExecutionLogController.deleteExecutionLog); + +/** + * GET /api/batch-execution-logs/latest/:batchConfigId + * 특정 배치의 최신 실행 로그 조회 + */ +router.get("/latest/:batchConfigId", authenticateToken, BatchExecutionLogController.getLatestExecutionLog); + +/** + * GET /api/batch-execution-logs/stats + * 배치 실행 통계 조회 + */ +router.get("/stats", authenticateToken, BatchExecutionLogController.getExecutionStats); + +export default router; diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts new file mode 100644 index 00000000..e7a43fff --- /dev/null +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -0,0 +1,64 @@ +// 배치관리 전용 라우트 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import { Router } from "express"; +import { BatchManagementController } from "../controllers/batchManagementController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +/** + * GET /api/batch-management/connections + * 사용 가능한 커넥션 목록 조회 + */ +router.get("/connections", BatchManagementController.getAvailableConnections); + +/** + * GET /api/batch-management/connections/:type/tables + * 내부 DB 테이블 목록 조회 + */ +router.get("/connections/:type/tables", authenticateToken, BatchManagementController.getTablesFromConnection); + +/** + * GET /api/batch-management/connections/:type/:id/tables + * 외부 DB 테이블 목록 조회 + */ +router.get("/connections/:type/:id/tables", authenticateToken, BatchManagementController.getTablesFromConnection); + +/** + * GET /api/batch-management/connections/:type/tables/:tableName/columns + * 내부 DB 테이블 컬럼 정보 조회 + */ +router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns); + +/** + * GET /api/batch-management/connections/:type/:id/tables/:tableName/columns + * 외부 DB 테이블 컬럼 정보 조회 + */ +router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns); + +/** + * POST /api/batch-management/batch-configs + * 배치 설정 생성 + */ +router.post("/batch-configs", authenticateToken, BatchManagementController.createBatchConfig); + +/** + * GET /api/batch-management/batch-configs + * 배치 설정 목록 조회 + */ +router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs); + +/** + * PUT /api/batch-management/batch-configs/:id + * 배치 설정 업데이트 + */ +router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig); + +/** + * POST /api/batch-management/batch-configs/:id/execute + * 배치 수동 실행 + */ +router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig); + +export default router; diff --git a/backend-node/src/routes/batchRoutes.ts b/backend-node/src/routes/batchRoutes.ts index 7788c4c9..c34ee9e5 100644 --- a/backend-node/src/routes/batchRoutes.ts +++ b/backend-node/src/routes/batchRoutes.ts @@ -3,7 +3,7 @@ import { Router } from "express"; import { BatchController } from "../controllers/batchController"; -import { authenticateToken } from "../middleware/auth"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); @@ -17,7 +17,7 @@ router.get("/", authenticateToken, BatchController.getBatchConfigs); * GET /api/batch-configs/connections * 사용 가능한 커넥션 목록 조회 */ -router.get("/connections", authenticateToken, BatchController.getAvailableConnections); +router.get("/connections", BatchController.getAvailableConnections); /** * GET /api/batch-configs/connections/:type/tables @@ -67,4 +67,4 @@ router.put("/:id", authenticateToken, BatchController.updateBatchConfig); */ router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig); -export default router; +export default router; \ No newline at end of file diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts new file mode 100644 index 00000000..2fee555a --- /dev/null +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -0,0 +1,299 @@ +// 배치 실행 로그 서비스 +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { + BatchExecutionLog, + CreateBatchExecutionLogRequest, + UpdateBatchExecutionLogRequest, + BatchExecutionLogFilter, + BatchExecutionLogWithConfig +} from "../types/batchExecutionLogTypes"; +import { ApiResponse } from "../types/batchTypes"; + +export class BatchExecutionLogService { + /** + * 배치 실행 로그 목록 조회 + */ + static async getExecutionLogs( + filter: BatchExecutionLogFilter = {} + ): Promise> { + try { + const { + batch_config_id, + execution_status, + start_date, + end_date, + page = 1, + limit = 50 + } = filter; + + const skip = (page - 1) * limit; + const take = limit; + + // WHERE 조건 구성 + const where: any = {}; + + if (batch_config_id) { + where.batch_config_id = batch_config_id; + } + + if (execution_status) { + where.execution_status = execution_status; + } + + if (start_date || end_date) { + where.start_time = {}; + if (start_date) { + where.start_time.gte = start_date; + } + if (end_date) { + where.start_time.lte = end_date; + } + } + + // 로그 조회 + const [logs, total] = await Promise.all([ + prisma.batch_execution_logs.findMany({ + where, + include: { + batch_config: { + select: { + id: true, + batch_name: true, + description: true, + cron_schedule: true, + is_active: true + } + } + }, + orderBy: { start_time: 'desc' }, + skip, + take + }), + prisma.batch_execution_logs.count({ where }) + ]); + + return { + success: true, + data: logs as BatchExecutionLogWithConfig[], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + }; + } catch (error) { + console.error("배치 실행 로그 조회 실패:", error); + return { + success: false, + message: "배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog( + data: CreateBatchExecutionLogRequest + ): Promise> { + try { + const log = await prisma.batch_execution_logs.create({ + data: { + batch_config_id: data.batch_config_id, + execution_status: data.execution_status, + start_time: data.start_time || new Date(), + end_time: data.end_time, + duration_ms: data.duration_ms, + total_records: data.total_records || 0, + success_records: data.success_records || 0, + failed_records: data.failed_records || 0, + error_message: data.error_message, + error_details: data.error_details, + server_name: data.server_name || process.env.HOSTNAME || 'unknown', + process_id: data.process_id || process.pid?.toString() + } + }); + + return { + success: true, + data: log as BatchExecutionLog, + message: "배치 실행 로그가 생성되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 생성 실패:", error); + return { + success: false, + message: "배치 실행 로그 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog( + id: number, + data: UpdateBatchExecutionLogRequest + ): Promise> { + try { + const log = await prisma.batch_execution_logs.update({ + where: { id }, + data: { + execution_status: data.execution_status, + end_time: data.end_time, + duration_ms: data.duration_ms, + total_records: data.total_records, + success_records: data.success_records, + failed_records: data.failed_records, + error_message: data.error_message, + error_details: data.error_details + } + }); + + return { + success: true, + data: log as BatchExecutionLog, + message: "배치 실행 로그가 업데이트되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 업데이트 실패:", error); + return { + success: false, + message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 로그 삭제 + */ + static async deleteExecutionLog(id: number): Promise> { + try { + await prisma.batch_execution_logs.delete({ + where: { id } + }); + + return { + success: true, + message: "배치 실행 로그가 삭제되었습니다." + }; + } catch (error) { + console.error("배치 실행 로그 삭제 실패:", error); + return { + success: false, + message: "배치 실행 로그 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 특정 배치의 최신 실행 로그 조회 + */ + static async getLatestExecutionLog( + batchConfigId: number + ): Promise> { + try { + const log = await prisma.batch_execution_logs.findFirst({ + where: { batch_config_id: batchConfigId }, + orderBy: { start_time: 'desc' } + }); + + return { + success: true, + data: log as BatchExecutionLog | null + }; + } catch (error) { + console.error("최신 배치 실행 로그 조회 실패:", error); + return { + success: false, + message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치 실행 통계 조회 + */ + static async getExecutionStats( + batchConfigId?: number, + startDate?: Date, + endDate?: Date + ): Promise> { + try { + const where: any = {}; + + if (batchConfigId) { + where.batch_config_id = batchConfigId; + } + + if (startDate || endDate) { + where.start_time = {}; + if (startDate) { + where.start_time.gte = startDate; + } + if (endDate) { + where.start_time.lte = endDate; + } + } + + const logs = await prisma.batch_execution_logs.findMany({ + where, + select: { + execution_status: true, + duration_ms: true, + total_records: true + } + }); + + const total_executions = logs.length; + const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length; + const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length; + const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0; + + const validDurations = logs + .filter((log: any) => log.duration_ms !== null) + .map((log: any) => log.duration_ms!); + const average_duration_ms = validDurations.length > 0 + ? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length + : 0; + + const total_records_processed = logs + .filter((log: any) => log.total_records !== null) + .reduce((sum: number, log: any) => sum + (log.total_records || 0), 0); + + return { + success: true, + data: { + total_executions, + success_count, + failed_count, + success_rate, + average_duration_ms, + total_records_processed + } + }; + } catch (error) { + console.error("배치 실행 통계 조회 실패:", error); + return { + success: false, + message: "배치 실행 통계 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts new file mode 100644 index 00000000..ee9027fe --- /dev/null +++ b/backend-node/src/services/batchExternalDbService.ts @@ -0,0 +1,686 @@ +// 배치관리 전용 외부 DB 서비스 +// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공 +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes"; + +export class BatchExternalDbService { + /** + * 배치관리용 외부 DB 연결 목록 조회 + */ + static async getAvailableConnections(): Promise>> { + try { + const connections: Array<{ + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; + }> = []; + + // 내부 DB 추가 + connections.push({ + type: 'internal', + name: '내부 데이터베이스 (PostgreSQL)', + db_type: 'postgresql' + }); + + // 활성화된 외부 DB 연결 조회 + const externalConnections = await prisma.external_db_connections.findMany({ + where: { is_active: 'Y' }, + select: { + id: true, + connection_name: true, + db_type: true, + description: true + }, + orderBy: { connection_name: 'asc' } + }); + + // 외부 DB 연결 추가 + externalConnections.forEach(conn => { + connections.push({ + type: 'external', + id: conn.id, + name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`, + db_type: conn.db_type || undefined + }); + }); + + return { + success: true, + data: connections, + message: `${connections.length}개의 연결을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 목록 조회 + */ + static async getTablesFromConnection( + connectionType: 'internal' | 'external', + connectionId?: number + ): Promise> { + try { + let tables: TableInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 테이블 조회 + const result = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 테이블 조회 + const tablesResult = await this.getExternalTables(connectionId); + if (tablesResult.success && tablesResult.data) { + tables = tablesResult.data; + } + } + + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 테이블 목록 조회 실패:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 컬럼 정보 조회 + */ + static async getTableColumns( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string + ): Promise> { + try { + console.log(`[BatchExternalDbService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + + let columns: ColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`); + + const result = await prisma.$queryRaw>` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); + + columns = result.map(row => ({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable, + column_default: row.column_default, + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 컬럼 조회 + console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await this.getExternalTableColumns(connectionId, tableName); + + console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult); + + if (columnsResult.success && columnsResult.data) { + columns = columnsResult.data; + } + } + + console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.` + }; + } catch (error) { + console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 목록 조회 (내부 구현) + */ + private static async getExternalTables(connectionId: number): Promise> { + try { + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + const tables = await connector.getTables(); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) + */ + private static async getExternalTableColumns(connectionId: number, tableName: string): Promise> { + try { + console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`); + + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`); + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { + id: connection.id, + connection_name: connection.connection_name, + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name + }); + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`); + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + + console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`); + + // 컬럼 정보 조회 + console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + const columns = await connector.getColumns(tableName); + + console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); + console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined'); + + // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 + const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { + console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); + + // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) + if (col.name && col.dataType !== undefined) { + const result = { + column_name: col.name, + data_type: col.dataType, + is_nullable: col.isNullable ? 'YES' : 'NO', + column_default: col.defaultValue || null, + }; + console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result); + return result; + } + // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} + else { + const result = { + column_name: col.column_name || col.COLUMN_NAME, + data_type: col.data_type || col.DATA_TYPE, + is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'), + column_default: col.column_default || col.COLUMN_DEFAULT || null, + }; + console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); + return result; + } + }); + + console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns); + + // 빈 배열인 경우 경고 로그 + if (!standardizedColumns || standardizedColumns.length === 0) { + console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`); + console.warn(`[BatchExternalDbService] 연결 정보:`, { + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name, + username: connection.username + }); + + // 테이블 존재 여부 확인 + console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`); + try { + const tables = await connector.getTables(); + console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name)); + + // 테이블명이 정확한지 확인 + const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase()); + console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`); + + // 정확한 테이블명 찾기 + const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase()); + if (exactTable) { + console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`); + } + + // 모든 테이블명 출력 + console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`)); + + // 테이블명 비교 + console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`); + console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({ + table_name: t.table_name, + matches: t.table_name.toLowerCase() === tableName.toLowerCase(), + exact_match: t.table_name === tableName + }))); + + // 정확한 테이블명으로 다시 시도 + if (exactTable && exactTable.table_name !== tableName) { + console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`); + try { + const correctColumns = await connector.getColumns(exactTable.table_name); + console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns); + } catch (correctError) { + console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError); + } + } + } catch (tableError) { + console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError); + } + } + + return { + success: true, + data: standardizedColumns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error); + console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace'); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에서 데이터 조회 + */ + static async getDataFromTable( + connectionId: number, + tableName: string, + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`); + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'postgresql'; + + if (dbType === 'oracle') { + query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; + } else { + query = `SELECT * FROM ${tableName} LIMIT ${limit}`; + } + + console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); + const result = await connector.executeQuery(query); + + console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`); + + return { + success: true, + data: result.rows + }; + } catch (error) { + console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에서 특정 컬럼들만 조회 + */ + static async getDataFromTableWithColumns( + connectionId: number, + tableName: string, + columns: string[], + limit: number = 100 + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`); + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'postgresql'; + const columnList = columns.join(', '); + + if (dbType === 'oracle') { + query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; + } else { + query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; + } + + console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); + const result = await connector.executeQuery(query); + + console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`); + + return { + success: true, + data: result.rows + }; + } catch (error) { + console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블에 데이터 삽입 + */ + static async insertDataToTable( + connectionId: number, + tableName: string, + data: any[] + ): Promise> { + try { + console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`); + + if (!data || data.length === 0) { + return { + success: true, + data: { successCount: 0, failedCount: 0 } + }; + } + + // 외부 DB 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "외부 DB 연결을 찾을 수 없습니다." + }; + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type || 'postgresql', + config, + connectionId + ); + + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + for (const record of data) { + try { + const columns = Object.keys(record); + const values = Object.values(record); + + // 값들을 SQL 문자열로 변환 (타입별 처리) + const formattedValues = values.map(value => { + if (value === null || value === undefined) { + return 'NULL'; + } else if (value instanceof Date) { + // Date 객체를 MySQL/MariaDB 형식으로 변환 + return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`; + } else if (typeof value === 'string') { + // 문자열이 날짜 형식인지 확인 + const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; + if (dateRegex.test(value)) { + // JavaScript Date 문자열을 MySQL 형식으로 변환 + const date = new Date(value); + return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`; + } else { + return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 + } + } else if (typeof value === 'number') { + return String(value); + } else if (typeof value === 'boolean') { + return value ? '1' : '0'; + } else { + // 기타 객체는 문자열로 변환 + return `'${String(value).replace(/'/g, "''")}'`; + } + }).join(', '); + + // Primary Key 컬럼 추정 + const primaryKeyColumn = columns.includes('id') ? 'id' : + columns.includes('user_id') ? 'user_id' : + columns[0]; + + // UPDATE SET 절 생성 (Primary Key 제외) + const updateColumns = columns.filter(col => col !== primaryKeyColumn); + + let query: string; + const dbType = connection.db_type?.toLowerCase() || 'mysql'; + + if (dbType === 'mysql' || dbType === 'mariadb') { + // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 + if (updateColumns.length > 0) { + const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', '); + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues}) + ON DUPLICATE KEY UPDATE ${updateSet}`; + } else { + // Primary Key만 있는 경우 IGNORE 사용 + query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`; + } + } else { + // 다른 DB는 기본 INSERT 사용 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`; + } + + await connector.executeQuery(query); + successCount++; + } catch (error) { + console.error(`외부 DB 레코드 UPSERT 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + + return { + success: true, + data: { successCount, failedCount } + }; + } catch (error) { + console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error); + return { + success: false, + message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} diff --git a/backend-node/src/services/batchManagementService.ts b/backend-node/src/services/batchManagementService.ts new file mode 100644 index 00000000..1d60c2fb --- /dev/null +++ b/backend-node/src/services/batchManagementService.ts @@ -0,0 +1,370 @@ +// 배치관리 전용 서비스 (기존 소스와 완전 분리) +// 작성일: 2024-12-24 + +import prisma from "../config/database"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; + +// 배치관리 전용 타입 정의 +export interface BatchConnectionInfo { + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; +} + +export interface BatchTableInfo { + table_name: string; + columns: BatchColumnInfo[]; + description?: string | null; +} + +export interface BatchColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +export interface BatchApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class BatchManagementService { + /** + * 배치관리용 연결 목록 조회 + */ + static async getAvailableConnections(): Promise> { + try { + const connections: BatchConnectionInfo[] = []; + + // 내부 DB 추가 + connections.push({ + type: 'internal', + name: '내부 데이터베이스 (PostgreSQL)', + db_type: 'postgresql' + }); + + // 활성화된 외부 DB 연결 조회 + const externalConnections = await prisma.external_db_connections.findMany({ + where: { is_active: 'Y' }, + select: { + id: true, + connection_name: true, + db_type: true, + description: true + }, + orderBy: { connection_name: 'asc' } + }); + + // 외부 DB 연결 추가 + externalConnections.forEach(conn => { + connections.push({ + type: 'external', + id: conn.id, + name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`, + db_type: conn.db_type || undefined + }); + }); + + return { + success: true, + data: connections, + message: `${connections.length}개의 연결을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 목록 조회 + */ + static async getTablesFromConnection( + connectionType: 'internal' | 'external', + connectionId?: number + ): Promise> { + try { + let tables: BatchTableInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 테이블 조회 + const result = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 테이블 조회 + const tablesResult = await this.getExternalTables(connectionId); + if (tablesResult.success && tablesResult.data) { + tables = tablesResult.data; + } + } + + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.` + }; + } catch (error) { + console.error("배치관리 테이블 목록 조회 실패:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 배치관리용 테이블 컬럼 정보 조회 + */ + static async getTableColumns( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string + ): Promise> { + try { + console.log(`[BatchManagementService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + + let columns: BatchColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`); + + const result = await prisma.$queryRaw>` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result); + + columns = result.map(row => ({ + column_name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable, + column_default: row.column_default, + })); + } else if (connectionType === 'external' && connectionId) { + // 외부 DB 컬럼 조회 + console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await this.getExternalTableColumns(connectionId, tableName); + + console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult); + + if (columnsResult.success && columnsResult.data) { + columns = columnsResult.data; + } + } + + console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.` + }; + } catch (error) { + console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 목록 조회 (내부 구현) + */ + private static async getExternalTables(connectionId: number): Promise> { + try { + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + const tables = await connector.getTables(); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) + */ + private static async getExternalTableColumns(connectionId: number, tableName: string): Promise> { + try { + console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`); + + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id: connectionId } + }); + + if (!connection) { + console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`); + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + console.log(`[BatchManagementService] 연결 정보 조회 성공:`, { + id: connection.id, + connection_name: connection.connection_name, + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name + }); + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`); + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId); + + console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`); + + // 컬럼 정보 조회 + console.log(`[BatchManagementService] connector.getColumns 호출 전`); + const columns = await connector.getColumns(tableName); + + console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns); + console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined'); + + // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 + const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => { + console.log(`[BatchManagementService] 컬럼 변환 중:`, col); + + // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) + if (col.name && col.dataType !== undefined) { + const result = { + column_name: col.name, + data_type: col.dataType, + is_nullable: col.isNullable ? 'YES' : 'NO', + column_default: col.defaultValue || null, + }; + console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result); + return result; + } + // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} + else { + const result = { + column_name: col.column_name || col.COLUMN_NAME, + data_type: col.data_type || col.DATA_TYPE, + is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'), + column_default: col.column_default || col.COLUMN_DEFAULT || null, + }; + console.log(`[BatchManagementService] 표준 구조로 변환:`, result); + return result; + } + }); + + console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns); + + return { + success: true, + data: standardizedColumns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error); + console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace'); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } +} diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts new file mode 100644 index 00000000..de03df33 --- /dev/null +++ b/backend-node/src/services/batchSchedulerService.ts @@ -0,0 +1,397 @@ +// 배치 스케줄러 서비스 +// 작성일: 2024-12-24 + +import * as cron from 'node-cron'; +import prisma from '../config/database'; +import { BatchService } from './batchService'; +import { BatchExecutionLogService } from './batchExecutionLogService'; +import { logger } from '../utils/logger'; + +export class BatchSchedulerService { + private static scheduledTasks: Map = new Map(); + private static isInitialized = false; + + /** + * 스케줄러 초기화 + */ + static async initialize() { + if (this.isInitialized) { + logger.info('배치 스케줄러가 이미 초기화되었습니다.'); + return; + } + + try { + logger.info('배치 스케줄러 초기화 시작...'); + + // 활성화된 배치 설정들을 로드하여 스케줄 등록 + await this.loadActiveBatchConfigs(); + + this.isInitialized = true; + logger.info('배치 스케줄러 초기화 완료'); + } catch (error) { + logger.error('배치 스케줄러 초기화 실패:', error); + throw error; + } + } + + /** + * 활성화된 배치 설정들을 로드하여 스케줄 등록 + */ + private static async loadActiveBatchConfigs() { + try { + const activeConfigs = await prisma.batch_configs.findMany({ + where: { + is_active: 'Y' + }, + include: { + batch_mappings: true + } + }); + + logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`); + + for (const config of activeConfigs) { + await this.scheduleBatchConfig(config); + } + } catch (error) { + logger.error('활성화된 배치 설정 로드 실패:', error); + throw error; + } + } + + /** + * 배치 설정을 스케줄에 등록 + */ + static async scheduleBatchConfig(config: any) { + try { + const { id, batch_name, cron_schedule } = config; + + // 기존 스케줄이 있다면 제거 + if (this.scheduledTasks.has(id)) { + this.scheduledTasks.get(id)?.stop(); + this.scheduledTasks.delete(id); + } + + // cron 스케줄 유효성 검사 + if (!cron.validate(cron_schedule)) { + logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`); + return; + } + + // 새로운 스케줄 등록 + const task = cron.schedule(cron_schedule, async () => { + await this.executeBatchConfig(config); + }); + + this.scheduledTasks.set(id, task); + logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule})`); + } catch (error) { + logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); + } + } + + /** + * 배치 설정 스케줄 제거 + */ + static async unscheduleBatchConfig(batchConfigId: number) { + try { + if (this.scheduledTasks.has(batchConfigId)) { + this.scheduledTasks.get(batchConfigId)?.stop(); + this.scheduledTasks.delete(batchConfigId); + logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`); + } + } catch (error) { + logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error); + } + } + + /** + * 배치 설정 업데이트 시 스케줄 재등록 + */ + static async updateBatchSchedule(configId: number) { + try { + // 기존 스케줄 제거 + await this.unscheduleBatchConfig(configId); + + // 업데이트된 배치 설정 조회 + const config = await prisma.batch_configs.findUnique({ + where: { id: configId }, + include: { batch_mappings: true } + }); + + if (!config) { + logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`); + return; + } + + // 활성화된 배치만 다시 스케줄 등록 + if (config.is_active === 'Y') { + await this.scheduleBatchConfig(config); + logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`); + } else { + logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`); + } + } catch (error) { + logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); + } + } + + /** + * 배치 설정 실행 + */ + private static async executeBatchConfig(config: any) { + const startTime = new Date(); + let executionLog: any = null; + + try { + logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + + // 실행 로그 생성 + const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ + batch_config_id: config.id, + execution_status: 'RUNNING', + start_time: startTime, + total_records: 0, + success_records: 0, + failed_records: 0 + }); + + if (!executionLogResponse.success || !executionLogResponse.data) { + logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message); + return; + } + + executionLog = executionLogResponse.data; + + // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) + const result = await this.executeBatchMappings(config); + + // 실행 로그 업데이트 (성공) + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { + execution_status: 'SUCCESS', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + total_records: result.totalRecords, + success_records: result.successRecords, + failed_records: result.failedRecords + }); + + logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`); + } catch (error) { + logger.error(`배치 실행 실패: ${config.batch_name}`, error); + + // 실행 로그 업데이트 (실패) + if (executionLog) { + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { + execution_status: 'FAILED', + end_time: new Date(), + duration_ms: Date.now() - startTime.getTime(), + error_message: error instanceof Error ? error.message : '알 수 없는 오류', + error_details: error instanceof Error ? error.stack : String(error) + }); + } + } + } + + /** + * 배치 매핑 실행 (수동 실행과 동일한 로직) + */ + private static async executeBatchMappings(config: any) { + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + if (!config.batch_mappings || config.batch_mappings.length === 0) { + logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); + return { totalRecords, successRecords, failedRecords }; + } + + // 테이블별로 매핑을 그룹화 + const tableGroups = new Map(); + + for (const mapping of config.batch_mappings) { + const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`; + if (!tableGroups.has(key)) { + tableGroups.set(key, []); + } + tableGroups.get(key)!.push(mapping); + } + + // 각 테이블 그룹별로 처리 + for (const [tableKey, mappings] of tableGroups) { + try { + const firstMapping = mappings[0]; + logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`); + + // FROM 테이블에서 매핑된 컬럼들만 조회 + const fromColumns = mappings.map((m: any) => m.from_column_name); + const fromData = await BatchService.getDataFromTableWithColumns( + firstMapping.from_table_name, + fromColumns, + firstMapping.from_connection_type as 'internal' | 'external', + firstMapping.from_connection_id || undefined + ); + totalRecords += fromData.length; + + // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + const mappedData = fromData.map(row => { + const mappedRow: any = {}; + for (const mapping of mappings) { + mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + } + return mappedRow; + }); + + // TO 테이블에 데이터 삽입 + const insertResult = await BatchService.insertDataToTable( + firstMapping.to_table_name, + mappedData, + firstMapping.to_connection_type as 'internal' | 'external', + firstMapping.to_connection_id || undefined + ); + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + logger.error(`테이블 처리 실패: ${tableKey}`, error); + failedRecords += 1; + } + } + + return { totalRecords, successRecords, failedRecords }; + } + + /** + * 배치 매핑 처리 (기존 메서드 - 사용 안 함) + */ + private static async processBatchMappings(config: any) { + const { batch_mappings } = config; + let totalRecords = 0; + let successRecords = 0; + let failedRecords = 0; + + if (!batch_mappings || batch_mappings.length === 0) { + logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); + return { totalRecords, successRecords, failedRecords }; + } + + for (const mapping of batch_mappings) { + try { + logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`); + + // FROM 테이블에서 데이터 조회 + const fromData = await this.getDataFromSource(mapping); + totalRecords += fromData.length; + + // TO 테이블에 데이터 삽입 + const insertResult = await this.insertDataToTarget(mapping, fromData); + successRecords += insertResult.successCount; + failedRecords += insertResult.failedCount; + + logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`); + } catch (error) { + logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error); + failedRecords += 1; + } + } + + return { totalRecords, successRecords, failedRecords }; + } + + /** + * FROM 테이블에서 데이터 조회 + */ + private static async getDataFromSource(mapping: any) { + try { + if (mapping.from_connection_type === 'internal') { + // 내부 DB에서 조회 + const result = await prisma.$queryRawUnsafe( + `SELECT * FROM ${mapping.from_table_name}` + ); + return result as any[]; + } else { + // 외부 DB에서 조회 (구현 필요) + logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.'); + return []; + } + } catch (error) { + logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error); + throw error; + } + } + + /** + * TO 테이블에 데이터 삽입 + */ + private static async insertDataToTarget(mapping: any, data: any[]) { + let successCount = 0; + let failedCount = 0; + + try { + if (mapping.to_connection_type === 'internal') { + // 내부 DB에 삽입 + for (const record of data) { + try { + // 매핑된 컬럼만 추출 + const mappedData = this.mapColumns(record, mapping); + + await prisma.$executeRawUnsafe( + `INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`, + ...Object.values(mappedData) + ); + successCount++; + } catch (error) { + logger.error(`레코드 삽입 실패:`, error); + failedCount++; + } + } + } else { + // 외부 DB에 삽입 (구현 필요) + logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.'); + failedCount = data.length; + } + } catch (error) { + logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error); + throw error; + } + + return { successCount, failedCount }; + } + + /** + * 컬럼 매핑 + */ + private static mapColumns(record: any, mapping: any) { + const mappedData: any = {}; + + // 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요) + mappedData[mapping.to_column_name] = record[mapping.from_column_name]; + + return mappedData; + } + + /** + * 모든 스케줄 중지 + */ + static async stopAllSchedules() { + try { + for (const [id, task] of this.scheduledTasks) { + task.stop(); + logger.info(`배치 스케줄 중지: ID ${id}`); + } + this.scheduledTasks.clear(); + this.isInitialized = false; + logger.info('모든 배치 스케줄이 중지되었습니다.'); + } catch (error) { + logger.error('배치 스케줄 중지 실패:', error); + } + } + + /** + * 현재 등록된 스케줄 목록 조회 + */ + static getScheduledTasks() { + return Array.from(this.scheduledTasks.keys()); + } +} diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index df321a0d..99660af9 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -1,7 +1,7 @@ // 배치관리 서비스 // 작성일: 2024-12-24 -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { BatchConfig, BatchMapping, @@ -12,12 +12,12 @@ import { ConnectionInfo, TableInfo, ColumnInfo, + CreateBatchConfigRequest, + UpdateBatchConfigRequest, } from "../types/batchTypes"; -import { ExternalDbConnectionService } from "./externalDbConnectionService"; +import { BatchExternalDbService } from "./batchExternalDbService"; import { DbConnectionManager } from "./dbConnectionManager"; -const prisma = new PrismaClient(); - export class BatchService { /** * 배치 설정 목록 조회 @@ -55,17 +55,32 @@ export class BatchService { ]; } - const batchConfigs = await prisma.batch_configs.findMany({ - where, - include: { - batch_mappings: true, - }, - orderBy: [{ is_active: "desc" }, { batch_name: "asc" }], - }); + const page = filter.page || 1; + const limit = filter.limit || 10; + const skip = (page - 1) * limit; + + const [batchConfigs, total] = await Promise.all([ + prisma.batch_configs.findMany({ + where, + include: { + batch_mappings: true, + }, + orderBy: [{ is_active: "desc" }, { batch_name: "asc" }], + skip, + take: limit, + }), + prisma.batch_configs.count({ where }), + ]); return { success: true, data: batchConfigs as BatchConfig[], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, }; } catch (error) { console.error("배치 설정 목록 조회 오류:", error); @@ -122,28 +137,18 @@ export class BatchService { * 배치 설정 생성 */ static async createBatchConfig( - data: BatchMappingRequest, + data: CreateBatchConfigRequest, userId?: string ): Promise> { try { - // 매핑 유효성 검사 - const validation = await this.validateBatchMappings(data.mappings); - if (!validation.isValid) { - return { - success: false, - message: "매핑 유효성 검사 실패", - error: validation.errors.join(", "), - }; - } - // 트랜잭션으로 배치 설정과 매핑 생성 const result = await prisma.$transaction(async (tx) => { // 배치 설정 생성 const batchConfig = await tx.batch_configs.create({ data: { - batch_name: data.batch_name, + batch_name: data.batchName, description: data.description, - cron_schedule: data.cron_schedule, + cron_schedule: data.cronSchedule, created_by: userId, updated_by: userId, }, @@ -198,7 +203,7 @@ export class BatchService { */ static async updateBatchConfig( id: number, - data: Partial, + data: UpdateBatchConfigRequest, userId?: string ): Promise> { try { @@ -215,18 +220,6 @@ export class BatchService { }; } - // 매핑이 제공된 경우 유효성 검사 - if (data.mappings) { - const validation = await this.validateBatchMappings(data.mappings); - if (!validation.isValid) { - return { - success: false, - message: "매핑 유효성 검사 실패", - error: validation.errors.join(", "), - }; - } - } - // 트랜잭션으로 업데이트 const result = await prisma.$transaction(async (tx) => { // 배치 설정 업데이트 @@ -234,9 +227,10 @@ export class BatchService { updated_by: userId, }; - if (data.batch_name) updateData.batch_name = data.batch_name; + if (data.batchName) updateData.batch_name = data.batchName; if (data.description !== undefined) updateData.description = data.description; - if (data.cron_schedule) updateData.cron_schedule = data.cron_schedule; + if (data.cronSchedule) updateData.cron_schedule = data.cronSchedule; + if (data.isActive !== undefined) updateData.is_active = data.isActive; const batchConfig = await tx.batch_configs.update({ where: { id }, @@ -354,16 +348,14 @@ export class BatchService { }); // 외부 DB 연결 조회 - const externalConnections = await ExternalDbConnectionService.getConnections({ - is_active: 'Y', - }); + const externalConnections = await BatchExternalDbService.getAvailableConnections(); if (externalConnections.success && externalConnections.data) { externalConnections.data.forEach((conn) => { connections.push({ type: 'external', id: conn.id, - name: conn.connection_name, + name: conn.name, db_type: conn.db_type, }); }); @@ -389,9 +381,9 @@ export class BatchService { static async getTablesFromConnection( connectionType: 'internal' | 'external', connectionId?: number - ): Promise> { + ): Promise> { try { - let tables: string[] = []; + let tables: TableInfo[] = []; if (connectionType === 'internal') { // 내부 DB 테이블 조회 @@ -402,10 +394,13 @@ export class BatchService { AND table_type = 'BASE TABLE' ORDER BY table_name `; - tables = result.map(row => row.table_name); + tables = result.map(row => ({ + table_name: row.table_name, + columns: [] + })); } else if (connectionType === 'external' && connectionId) { // 외부 DB 테이블 조회 - const tablesResult = await ExternalDbConnectionService.getTables(connectionId); + const tablesResult = await BatchExternalDbService.getTablesFromConnection(connectionType, connectionId); if (tablesResult.success && tablesResult.data) { tables = tablesResult.data; } @@ -430,14 +425,22 @@ export class BatchService { */ static async getTableColumns( connectionType: 'internal' | 'external', - tableName: string, - connectionId?: number + connectionId: number | undefined, + tableName: string ): Promise> { try { + console.log(`[BatchService] getTableColumns 호출:`, { + connectionType, + connectionId, + tableName + }); + let columns: ColumnInfo[] = []; - + if (connectionType === 'internal') { // 내부 DB 컬럼 조회 + console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); + const result = await prisma.$queryRaw ({ column_name: row.column_name, data_type: row.data_type, - is_nullable: row.is_nullable === 'YES', + is_nullable: row.is_nullable, column_default: row.column_default, })); } else if (connectionType === 'external' && connectionId) { // 외부 DB 컬럼 조회 - const columnsResult = await ExternalDbConnectionService.getTableColumns( + console.log(`[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`); + + const columnsResult = await BatchExternalDbService.getTableColumns( + connectionType, connectionId, tableName ); + + console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); + if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data.map(col => ({ - column_name: col.column_name, - data_type: col.data_type, - is_nullable: col.is_nullable, - column_default: col.column_default, - })); + columns = columnsResult.data; } + + console.log(`[BatchService] 외부 DB 컬럼:`, columns); } return { @@ -491,6 +499,228 @@ export class BatchService { } } + /** + * 배치 실행 로그 생성 + */ + static async createExecutionLog(data: { + batch_config_id: number; + execution_status: string; + start_time: Date; + total_records: number; + success_records: number; + failed_records: number; + }): Promise { + try { + const executionLog = await prisma.batch_execution_logs.create({ + data: { + batch_config_id: data.batch_config_id, + execution_status: data.execution_status, + start_time: data.start_time, + total_records: data.total_records, + success_records: data.success_records, + failed_records: data.failed_records, + }, + }); + + return executionLog; + } catch (error) { + console.error("배치 실행 로그 생성 오류:", error); + throw error; + } + } + + /** + * 배치 실행 로그 업데이트 + */ + static async updateExecutionLog( + id: number, + data: { + execution_status?: string; + end_time?: Date; + duration_ms?: number; + total_records?: number; + success_records?: number; + failed_records?: number; + error_message?: string; + } + ): Promise { + try { + await prisma.batch_execution_logs.update({ + where: { id }, + data, + }); + } catch (error) { + console.error("배치 실행 로그 업데이트 오류:", error); + throw error; + } + } + + /** + * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async getDataFromTable( + tableName: string, + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise { + try { + console.log(`[BatchService] 테이블에서 데이터 조회: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''})`); + + if (connectionType === 'internal') { + // 내부 DB에서 데이터 조회 + const result = await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} LIMIT 100`); + console.log(`[BatchService] 내부 DB 데이터 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`); + return result as any[]; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에서 데이터 조회 + const result = await BatchExternalDbService.getDataFromTable(connectionId, tableName); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 데이터 조회 결과: ${result.data.length}개 레코드`); + return result.data; + } else { + console.error(`외부 DB 데이터 조회 실패: ${result.message}`); + return []; + } + } else { + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 데이터 조회 오류 (${tableName}):`, error); + throw error; + } + } + + /** + * 테이블에서 특정 컬럼들만 조회 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async getDataFromTableWithColumns( + tableName: string, + columns: string[], + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise { + try { + console.log(`[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(', ')}) (${connectionType}${connectionId ? `:${connectionId}` : ''})`); + + if (connectionType === 'internal') { + // 내부 DB에서 특정 컬럼만 조회 + const columnList = columns.join(', '); + const result = await prisma.$queryRawUnsafe(`SELECT ${columnList} FROM ${tableName} LIMIT 100`); + console.log(`[BatchService] 내부 DB 특정 컬럼 조회 결과: ${Array.isArray(result) ? result.length : 0}개 레코드`); + return result as any[]; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에서 특정 컬럼만 조회 + const result = await BatchExternalDbService.getDataFromTableWithColumns(connectionId, tableName, columns); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드`); + return result.data; + } else { + console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); + return []; + } + } else { + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + throw error; + } + } + + /** + * 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분) + */ + static async insertDataToTable( + tableName: string, + data: any[], + connectionType: 'internal' | 'external' = 'internal', + connectionId?: number + ): Promise<{ + successCount: number; + failedCount: number; + }> { + try { + console.log(`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ''}), ${data.length}개 레코드`); + + if (!data || data.length === 0) { + return { successCount: 0, failedCount: 0 }; + } + + if (connectionType === 'internal') { + // 내부 DB에 데이터 삽입 + let successCount = 0; + let failedCount = 0; + + // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + for (const record of data) { + try { + // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) + const columns = Object.keys(record); + const values = Object.values(record).map(value => { + // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) + if (value instanceof Date) { + return value.toISOString(); + } + // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 + if (typeof value === 'string') { + const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; + if (dateRegex.test(value)) { + return new Date(value).toISOString(); + } + } + return value; + }); + const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); + + // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) + const primaryKeyColumn = columns.includes('id') ? 'id' : + columns.includes('user_id') ? 'user_id' : + columns[0]; + + // UPDATE SET 절 생성 (Primary Key 제외) + const updateColumns = columns.filter(col => col !== primaryKeyColumn); + const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', '); + + let query: string; + if (updateSet) { + // UPSERT: 중복 시 업데이트 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) + ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`; + } else { + // Primary Key만 있는 경우 중복 시 무시 + query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) + ON CONFLICT (${primaryKeyColumn}) DO NOTHING`; + } + + await prisma.$executeRawUnsafe(query, ...values); + successCount++; + } catch (error) { + console.error(`레코드 UPSERT 실패:`, error); + failedCount++; + } + } + + console.log(`[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`); + return { successCount, failedCount }; + } else if (connectionType === 'external' && connectionId) { + // 외부 DB에 데이터 삽입 + const result = await BatchExternalDbService.insertDataToTable(connectionId, tableName, data); + if (result.success && result.data) { + console.log(`[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개`); + return result.data; + } else { + console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + return { successCount: 0, failedCount: data.length }; + } + } else { + throw new Error(`잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}`); + } + } catch (error) { + console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); + throw error; + } + } + /** * 배치 매핑 유효성 검사 */ diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index bd9f8e86..4a39e614 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -1,7 +1,7 @@ // 외부 DB 연결 서비스 // 작성일: 2024-12-17 -import { PrismaClient } from "@prisma/client"; +import prisma from "../config/database"; import { ExternalDbConnection, ExternalDbConnectionFilter, @@ -9,9 +9,7 @@ import { TableInfo, } from "../types/externalDbTypes"; import { PasswordEncryption } from "../utils/passwordEncryption"; -import { DbConnectionManager } from "./dbConnectionManager"; - -const prisma = new PrismaClient(); +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; export class ExternalDbConnectionService { /** @@ -81,6 +79,93 @@ export class ExternalDbConnectionService { } } + /** + * DB 타입별로 그룹화된 외부 DB 연결 목록 조회 + */ + static async getConnectionsGroupedByType( + filter: ExternalDbConnectionFilter = {} + ): Promise>> { + try { + // 기본 연결 목록 조회 + const connectionsResult = await this.getConnections(filter); + + if (!connectionsResult.success || !connectionsResult.data) { + return { + success: false, + message: "연결 목록 조회에 실패했습니다." + }; + } + + // DB 타입 카테고리 정보 조회 + const categories = await prisma.db_type_categories.findMany({ + where: { is_active: true }, + orderBy: [ + { sort_order: 'asc' }, + { display_name: 'asc' } + ] + }); + + // DB 타입별로 그룹화 + const groupedConnections: Record = {}; + + // 카테고리 정보를 포함한 그룹 초기화 + categories.forEach((category: any) => { + groupedConnections[category.type_code] = { + category: { + type_code: category.type_code, + display_name: category.display_name, + icon: category.icon, + color: category.color, + sort_order: category.sort_order + }, + connections: [] + }; + }); + + // 연결을 해당 타입 그룹에 배치 + connectionsResult.data.forEach(connection => { + if (groupedConnections[connection.db_type]) { + groupedConnections[connection.db_type].connections.push(connection); + } else { + // 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가 + if (!groupedConnections['other']) { + groupedConnections['other'] = { + category: { + type_code: 'other', + display_name: '기타', + icon: 'database', + color: '#6B7280', + sort_order: 999 + }, + connections: [] + }; + } + groupedConnections['other'].connections.push(connection); + } + }); + + // 연결이 없는 빈 그룹 제거 + Object.keys(groupedConnections).forEach(key => { + if (groupedConnections[key].connections.length === 0) { + delete groupedConnections[key]; + } + }); + + return { + success: true, + data: groupedConnections, + message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.` + }; + } catch (error) { + console.error("그룹화된 연결 목록 조회 실패:", error); + return { + success: false, + message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + /** * 특정 외부 DB 연결 조회 */ @@ -239,13 +324,40 @@ export class ExternalDbConnectionService { } } + // 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행 + if (data.password && data.password !== "***ENCRYPTED***") { + // 임시 연결 설정으로 테스트 + const testConfig = { + host: data.host || existingConnection.host, + port: data.port || existingConnection.port, + database: data.database_name || existingConnection.database_name, + user: data.username || existingConnection.username, + password: data.password, // 새로 입력된 비밀번호로 테스트 + connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined, + queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined, + ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false + }; + + // 연결 테스트 수행 + const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id); + const testResult = await connector.testConnection(); + + if (!testResult.success) { + return { + success: false, + message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.", + error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined + }; + } + } + // 업데이트 데이터 준비 const updateData: any = { ...data, updated_date: new Date(), }; - // 비밀번호가 변경된 경우 암호화 + // 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후) if (data.password && data.password !== "***ENCRYPTED***") { updateData.password = PasswordEncryption.encrypt(data.password); } else { @@ -320,7 +432,8 @@ export class ExternalDbConnectionService { * 데이터베이스 연결 테스트 (ID 기반) */ static async testConnectionById( - id: number + id: number, + testData?: { password?: string } ): Promise { try { // 저장된 연결 정보 조회 @@ -339,9 +452,17 @@ export class ExternalDbConnectionService { }; } - // 비밀번호 복호화 - const decryptedPassword = await this.getDecryptedPassword(id); - if (!decryptedPassword) { + // 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화) + let password: string | null; + if (testData?.password) { + password = testData.password; + console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`); + } else { + password = await this.getDecryptedPassword(id); + console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`); + } + + if (!password) { return { success: false, message: "비밀번호 복호화에 실패했습니다.", @@ -358,14 +479,46 @@ export class ExternalDbConnectionService { port: connection.port, database: connection.database_name, user: connection.username, - password: decryptedPassword, + password: password, connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 연결 테스트 - return await DbConnectionManager.testConnection(id, connection.db_type, config); + // 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음) + let connector: any; + switch (connection.db_type.toLowerCase()) { + case 'postgresql': + const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector'); + connector = new PostgreSQLConnector(config); + break; + case 'oracle': + const { OracleConnector } = await import('../database/OracleConnector'); + connector = new OracleConnector(config); + break; + case 'mariadb': + case 'mysql': + const { MariaDBConnector } = await import('../database/MariaDBConnector'); + connector = new MariaDBConnector(config); + break; + case 'mssql': + const { MSSQLConnector } = await import('../database/MSSQLConnector'); + connector = new MSSQLConnector(config); + break; + default: + throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`); + } + + console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`); + + const testResult = await connector.testConnection(); + console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`); + + return { + success: testResult.success, + message: testResult.message, + details: testResult.details + }; } catch (error) { return { success: false, @@ -416,7 +569,7 @@ export class ExternalDbConnectionService { } // DB 타입 유효성 검사 - const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"]; + const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"]; if (!validDbTypes.includes(data.db_type)) { throw new Error("지원하지 않는 DB 타입입니다."); } @@ -487,8 +640,9 @@ export class ExternalDbConnectionService { ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 쿼리 실행 - const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query); + // DatabaseConnectorFactory를 통한 쿼리 실행 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); + const result = await connector.executeQuery(query); return { success: true, @@ -595,8 +749,9 @@ export class ExternalDbConnectionService { ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // DbConnectionManager를 통한 테이블 목록 조회 - const tables = await DbConnectionManager.getTables(id, connection.db_type, config); + // DatabaseConnectorFactory를 통한 테이블 목록 조회 + const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); + const tables = await connector.getTables(); return { success: true, @@ -676,4 +831,58 @@ export class ExternalDbConnectionService { } } + /** + * 특정 테이블의 컬럼 정보 조회 + */ + static async getTableColumns(connectionId: number, tableName: string): Promise> { + let client: any = null; + + try { + const connection = await this.getConnectionById(connectionId); + if (!connection.success || !connection.data) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + const connectionData = connection.data; + + // 비밀번호 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connectionData.password); + + // 연결 설정 준비 + const config = { + host: connectionData.host, + port: connectionData.port, + database: connectionData.database_name, + user: connectionData.username, // ConnectionConfig에서는 user 사용 + password: decryptedPassword, + connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined, + ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // 데이터베이스 타입에 따른 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId); + + // 컬럼 정보 조회 + const columns = await connector.getColumns(tableName); + + + return { + success: true, + data: columns, + message: "컬럼 정보를 조회했습니다." + }; + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + } diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts new file mode 100644 index 00000000..1f3ecff0 --- /dev/null +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -0,0 +1,63 @@ +// 배치 실행 로그 타입 정의 +// 작성일: 2024-12-24 + +export interface BatchExecutionLog { + id?: number; + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + start_time: Date; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; + server_name?: string | null; + process_id?: string | null; +} + +export interface CreateBatchExecutionLogRequest { + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + start_time?: Date; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; + server_name?: string | null; + process_id?: string | null; +} + +export interface UpdateBatchExecutionLogRequest { + execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; + end_time?: Date | null; + duration_ms?: number | null; + total_records?: number | null; + success_records?: number | null; + failed_records?: number | null; + error_message?: string | null; + error_details?: string | null; +} + +export interface BatchExecutionLogFilter { + batch_config_id?: number; + execution_status?: string; + start_date?: Date; + end_date?: Date; + page?: number; + limit?: number; +} + +export interface BatchExecutionLogWithConfig extends BatchExecutionLog { + batch_config?: { + id: number; + batch_name: string; + description?: string | null; + cron_schedule: string; + is_active?: string | null; + }; +} diff --git a/backend-node/src/types/batchManagement.ts b/backend-node/src/types/batchManagement.ts new file mode 100644 index 00000000..407100ac --- /dev/null +++ b/backend-node/src/types/batchManagement.ts @@ -0,0 +1,98 @@ +// 배치 관리 관련 타입 정의 +// 작성일: 2024-12-23 + +export interface BatchJob { + id?: number; + job_name: string; + description?: string | null; + job_type: string; + schedule_cron?: string | null; + is_active: string; // 'Y' | 'N' + config_json?: Record | null; + last_executed_at?: Date | null; + next_execution_at?: Date | null; + execution_count: number; + success_count: number; + failure_count: number; + created_date?: Date | null; + created_by?: string | null; + updated_date?: Date | null; + updated_by?: string | null; + company_code: string; +} + +export interface BatchJobFilter { + job_name?: string; + job_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface BatchExecution { + id?: number; + job_id: number; + execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + started_at?: Date; + completed_at?: Date; + execution_time_ms?: number; + result_data?: Record; + error_message?: string; + log_details?: string; + created_date?: Date; +} + +export interface BatchSchedule { + id?: number; + job_id: number; + schedule_name: string; + cron_expression: string; + timezone?: string; + is_active: string; + last_triggered_at?: Date; + next_trigger_at?: Date; + created_date?: Date; + created_by?: string; +} + +export interface BatchMonitoring { + total_jobs: number; + active_jobs: number; + running_jobs: number; + failed_jobs_today: number; + successful_jobs_today: number; + recent_executions: BatchExecution[]; +} + +// 배치 작업 타입 옵션 +export const BATCH_JOB_TYPE_OPTIONS = [ + { value: 'collection', label: '데이터 수집' }, + { value: 'sync', label: '데이터 동기화' }, + { value: 'cleanup', label: '데이터 정리' }, + { value: 'custom', label: '사용자 정의' }, +]; + +// 실행 상태 옵션 +export const EXECUTION_STATUS_OPTIONS = [ + { value: 'pending', label: '대기 중' }, + { value: 'running', label: '실행 중' }, + { value: 'completed', label: '완료' }, + { value: 'failed', label: '실패' }, + { value: 'cancelled', label: '취소됨' }, +]; + +// 스케줄 프리셋 +export const SCHEDULE_PRESETS = [ + { value: '0 */1 * * *', label: '매시간' }, + { value: '0 0 */6 * *', label: '6시간마다' }, + { value: '0 0 * * *', label: '매일 자정' }, + { value: '0 0 * * 0', label: '매주 일요일' }, + { value: '0 0 1 * *', label: '매월 1일' }, +]; + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 4a470eb5..2cba6ace 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -39,6 +39,8 @@ export interface BatchMapping { } export interface BatchConfigFilter { + page?: number; + limit?: number; batch_name?: string; is_active?: string; company_code?: string; @@ -55,20 +57,43 @@ export interface ConnectionInfo { export interface TableInfo { table_name: string; columns: ColumnInfo[]; + description?: string | null; } export interface ColumnInfo { column_name: string; data_type: string; - is_nullable?: boolean; - column_default?: string; + is_nullable?: string; + column_default?: string | null; } export interface BatchMappingRequest { - batch_name: string; + from_connection_type: 'internal' | 'external'; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + to_connection_type: 'internal' | 'external'; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + mapping_order?: number; +} + +export interface CreateBatchConfigRequest { + batchName: string; description?: string; - cron_schedule: string; - mappings: BatchMapping[]; + cronSchedule: string; + mappings: BatchMappingRequest[]; +} + +export interface UpdateBatchConfigRequest { + batchName?: string; + description?: string; + cronSchedule?: string; + mappings?: BatchMappingRequest[]; + isActive?: string; } export interface BatchValidationResult { @@ -82,4 +107,10 @@ export interface ApiResponse { data?: T; message?: string; error?: string; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; } diff --git a/backend-node/src/types/oracledb.d.ts b/backend-node/src/types/oracledb.d.ts new file mode 100644 index 00000000..48974ff3 --- /dev/null +++ b/backend-node/src/types/oracledb.d.ts @@ -0,0 +1,17 @@ +declare module 'oracledb' { + export interface Connection { + execute(sql: string, bindParams?: any, options?: any): Promise; + close(): Promise; + } + + export interface ConnectionConfig { + user: string; + password: string; + connectString: string; + } + + export function getConnection(config: ConnectionConfig): Promise; + export function createPool(config: any): Promise; + export function getPool(): any; + export function close(): Promise; +} diff --git a/backend-node/tsconfig.json b/backend-node/tsconfig.json index 95e2dd18..848784d8 100644 --- a/backend-node/tsconfig.json +++ b/backend-node/tsconfig.json @@ -33,6 +33,6 @@ "@/validators/*": ["src/validators/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "src/types/**/*.d.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx new file mode 100644 index 00000000..0e59cb46 --- /dev/null +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -0,0 +1,507 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { + BatchManagementAPI, + BatchConnectionInfo, + BatchColumnInfo, +} from "@/lib/api/batchManagement"; + +interface MappingState { + from: { + connection: BatchConnectionInfo | null; + table: string; + column: BatchColumnInfo | null; + } | null; + to: { + connection: BatchConnectionInfo | null; + table: string; + column: BatchColumnInfo | null; + } | null; +} + +export default function BatchManagementNewPage() { + // 기본 상태 + const [batchName, setBatchName] = useState(""); + const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); + const [description, setDescription] = useState(""); + + // 커넥션 및 테이블 데이터 + const [connections, setConnections] = useState([]); + const [fromTables, setFromTables] = useState([]); + const [toTables, setToTables] = useState([]); + const [fromColumns, setFromColumns] = useState([]); + const [toColumns, setToColumns] = useState([]); + + // 선택된 상태 + const [fromConnection, setFromConnection] = useState(null); + const [toConnection, setToConnection] = useState(null); + const [fromTable, setFromTable] = useState(""); + const [toTable, setToTable] = useState(""); + const [selectedFromColumn, setSelectedFromColumn] = useState(null); + + // 매핑 상태 + const [mappings, setMappings] = useState([]); + + // 초기 데이터 로드 + useEffect(() => { + loadConnections(); + }, []); + + // 커넥션 목록 로드 + const loadConnections = async () => { + try { + const data = await BatchManagementAPI.getAvailableConnections(); + setConnections(Array.isArray(data) ? data : []); + } catch (error) { + console.error("커넥션 목록 로드 오류:", error); + toast.error("커넥션 목록을 불러오는데 실패했습니다."); + setConnections([]); // 오류 시 빈 배열로 설정 + } + }; + + // FROM 커넥션 변경 시 테이블 로드 + const handleFromConnectionChange = async (connectionId: string) => { + if (connectionId === 'unknown') return; + + const connection = connections.find((c: BatchConnectionInfo) => + c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId + ); + + if (!connection) return; + + setFromConnection(connection); + setFromTable(""); + setFromColumns([]); + setSelectedFromColumn(null); + + try { + const tables = await BatchManagementAPI.getTablesFromConnection( + connection.type, + connection.id + ); + setFromTables(Array.isArray(tables) ? tables : []); + } catch (error) { + console.error("FROM 테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + setFromTables([]); // 오류 시 빈 배열로 설정 + } + }; + + // TO 커넥션 변경 시 테이블 로드 + const handleToConnectionChange = async (connectionId: string) => { + if (connectionId === 'unknown') return; + + const connection = connections.find((c: BatchConnectionInfo) => + c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId + ); + + if (!connection) return; + + setToConnection(connection); + setToTable(""); + setToColumns([]); + + try { + const tables = await BatchManagementAPI.getTablesFromConnection( + connection.type, + connection.id + ); + setToTables(Array.isArray(tables) ? tables : []); + } catch (error) { + console.error("TO 테이블 목록 로드 오류:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + setToTables([]); // 오류 시 빈 배열로 설정 + } + }; + + // FROM 테이블 변경 시 컬럼 로드 + const handleFromTableChange = async (tableName: string) => { + if (!fromConnection) return; + + setFromTable(tableName); + setSelectedFromColumn(null); + + try { + const columns = await BatchManagementAPI.getTableColumns( + fromConnection.type, + tableName, + fromConnection.id + ); + setFromColumns(Array.isArray(columns) ? columns : []); + } catch (error) { + console.error("FROM 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setFromColumns([]); // 오류 시 빈 배열로 설정 + } + }; + + // TO 테이블 변경 시 컬럼 로드 + const handleToTableChange = async (tableName: string) => { + if (!toConnection) return; + + console.log("TO 테이블 변경:", { + tableName, + connectionType: toConnection.type, + connectionId: toConnection.id + }); + + setToTable(tableName); + + try { + const columns = await BatchManagementAPI.getTableColumns( + toConnection.type, + tableName, + toConnection.id + ); + console.log("TO 컬럼 목록 로드 성공:", columns); + setToColumns(Array.isArray(columns) ? columns : []); + } catch (error) { + console.error("TO 컬럼 목록 로드 오류:", error); + toast.error("컬럼 목록을 불러오는데 실패했습니다."); + setToColumns([]); // 오류 시 빈 배열로 설정 + } + }; + + // FROM 컬럼 클릭 + const handleFromColumnClick = (column: BatchColumnInfo) => { + setSelectedFromColumn(column); + }; + + // TO 컬럼 클릭 (매핑 생성) + const handleToColumnClick = (column: BatchColumnInfo) => { + if (!selectedFromColumn || !fromConnection || !toConnection) { + toast.error("FROM 컬럼을 먼저 선택해주세요."); + return; + } + + // N:1 매핑 방지 (여러 FROM 컬럼이 같은 TO 컬럼에 매핑되는 것 방지) + const isAlreadyMapped = mappings.some(mapping => + mapping.to?.connection?.type === toConnection.type && + mapping.to?.connection?.id === toConnection.id && + mapping.to?.table === toTable && + mapping.to?.column?.column_name === column.column_name + ); + + if (isAlreadyMapped) { + toast.error("이미 매핑된 TO 컬럼입니다. N:1 매핑은 허용되지 않습니다."); + return; + } + + // 새 매핑 추가 + const newMapping: MappingState = { + from: { + connection: fromConnection, + table: fromTable, + column: selectedFromColumn + }, + to: { + connection: toConnection, + table: toTable, + column: column + } + }; + + setMappings([...mappings, newMapping]); + setSelectedFromColumn(null); + toast.success("매핑이 추가되었습니다."); + }; + + // 매핑 삭제 + const removeMapping = (index: number) => { + setMappings(mappings.filter((_, i) => i !== index)); + toast.success("매핑이 삭제되었습니다."); + }; + + // 컬럼이 이미 매핑되었는지 확인 + const isColumnMapped = ( + connectionType: 'internal' | 'external', + connectionId: number | undefined, + tableName: string, + columnName: string + ): boolean => { + return mappings.some(mapping => + mapping.to?.connection?.type === connectionType && + mapping.to?.connection?.id === connectionId && + mapping.to?.table === tableName && + mapping.to?.column?.column_name === columnName + ); + }; + + // 배치 설정 저장 + const handleSave = () => { + if (!batchName.trim()) { + toast.error("배치명을 입력해주세요."); + return; + } + + if (mappings.length === 0) { + toast.error("최소 하나의 매핑을 설정해주세요."); + return; + } + + // TODO: 실제 저장 로직 구현 + console.log("배치 설정 저장:", { + batchName, + cronSchedule, + description, + mappings + }); + + toast.success("배치 설정이 저장되었습니다."); + }; + + return ( +
+
+

배치관리 시스템 (새 버전)

+
+ + +
+
+ + {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ + setBatchName(e.target.value)} + placeholder="배치명을 입력하세요" + /> +
+
+ + setCronSchedule(e.target.value)} + placeholder="0 12 * * *" + /> +
+
+
+ +