배치관리 중간커밋

This commit is contained in:
hjjeong 2025-09-24 10:46:55 +09:00
parent d9270e6307
commit 4abf5b31c0
12 changed files with 2857 additions and 41 deletions

View File

@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.7.1",
"@prisma/client": "^6.16.2",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
@ -24,7 +24,6 @@
"mysql2": "^3.15.0",
"nodemailer": "^6.9.7",
"pg": "^8.16.3",
"prisma": "^5.7.1",
"redis": "^4.6.10",
"winston": "^3.11.0"
},
@ -50,6 +49,7 @@
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"prettier": "^3.1.0",
"prisma": "^6.16.2",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
@ -1965,66 +1965,88 @@
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
"integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*"
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz",
"integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.16.12",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz",
"integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz",
"integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
"@prisma/debug": "6.16.2",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/fetch-engine": "6.16.2",
"@prisma/get-platform": "6.16.2"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz",
"integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
"@prisma/debug": "6.16.2",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/get-platform": "6.16.2"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz",
"integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
"@prisma/debug": "6.16.2"
}
},
"node_modules/@redis/bloom": {
@ -2764,6 +2786,13 @@
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@ -3942,6 +3971,65 @@
"node": ">= 0.8"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^16.6.1",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
"jiti": "^2.4.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^1.0.0",
"pkg-types": "^2.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -4093,6 +4181,16 @@
"node": ">=8"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/cjs-module-lexer": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
@ -4295,6 +4393,23 @@
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -4458,6 +4573,23 @@
"node": ">=0.10.0"
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -4485,6 +4617,13 @@
"node": ">= 0.8"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@ -4662,6 +4801,17 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "3.16.12",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz",
"integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.207",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",
@ -4689,6 +4839,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
@ -5125,6 +5285,36 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5413,6 +5603,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -5530,6 +5721,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -6672,6 +6881,16 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jiti": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz",
"integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
@ -7273,6 +7492,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -7395,6 +7621,26 @@
"node": ">=8"
}
},
"node_modules/nypm": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -7416,6 +7662,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -7626,6 +7879,20 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@ -7814,6 +8081,18 @@
"node": ">=8"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -7908,22 +8187,29 @@
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
"node": ">=18.18"
},
"optionalDependencies": {
"fsevents": "2.3.3"
"peerDependencies": {
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/process-nextick-args": {
@ -7986,7 +8272,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "individual",
@ -8059,6 +8345,17 @@
"node": ">= 0.8"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -8838,6 +9135,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -9075,7 +9379,7 @@
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@ -27,7 +27,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.7.1",
"@prisma/client": "^6.16.2",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
@ -42,7 +42,6 @@
"mysql2": "^3.15.0",
"nodemailer": "^6.9.7",
"pg": "^8.16.3",
"prisma": "^5.7.1",
"redis": "^4.6.10",
"winston": "^3.11.0"
},
@ -68,6 +67,7 @@
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"prettier": "^3.1.0",
"prisma": "^6.16.2",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",

View File

@ -65,6 +65,58 @@ model external_db_connections {
@@index([connection_name], map: "idx_external_db_connections_name")
}
// 배치관리 테이블들
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)
// 관계 설정
batch_mappings batch_mappings[]
@@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)
// 관계 설정
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 admin_supply_mng {
objid Decimal @id @default(0) @db.Decimal
supply_code String? @default("NULL::character varying") @db.VarChar(100)

View File

@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import batchRoutes from "./routes/batchRoutes";
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
// import userRoutes from './routes/userRoutes';
@ -127,6 +128,7 @@ app.use("/api/screen", screenStandardRoutes);
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/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
// app.use('/api/users', userRoutes);

View File

@ -0,0 +1,312 @@
// 배치관리 컨트롤러
// 작성일: 2024-12-24
import { Request, Response } from "express";
import { BatchService } from "../services/batchService";
import { BatchConfigFilter, BatchMappingRequest } from "../types/batchTypes";
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
username: string;
companyCode: string;
};
}
export class BatchController {
/**
*
* GET /api/batch-configs
*/
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
try {
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,
};
// 빈 값 제거
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);
}
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
return 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<BatchMappingRequest> = 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 : "알 수 없는 오류",
});
}
}
/**
*
* GET /api/batch-configs/connections
*/
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
try {
const result = await BatchService.getAvailableConnections();
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 : "알 수 없는 오류",
});
}
}
/**
*
* 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') {
return res.status(400).json({
success: false,
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
);
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 : "알 수 없는 오류",
});
}
}
/**
*
* 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') {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 타입입니다. (internal 또는 external)",
});
}
if (connectionType === 'external' && (!connectionId || isNaN(connectionId))) {
return res.status(400).json({
success: false,
message: "외부 커넥션의 경우 유효한 커넥션 ID가 필요합니다.",
});
}
if (!tableName) {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
});
}
const result = await BatchService.getTableColumns(
connectionType,
tableName,
connectionId
);
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 : "알 수 없는 오류",
});
}
}
}

View File

@ -0,0 +1,70 @@
// 배치관리 라우트
// 작성일: 2024-12-24
import { Router } from "express";
import { BatchController } from "../controllers/batchController";
import { authenticateToken } from "../middleware/auth";
const router = Router();
/**
* GET /api/batch-configs
*
*/
router.get("/", authenticateToken, BatchController.getBatchConfigs);
/**
* GET /api/batch-configs/connections
*
*/
router.get("/connections", authenticateToken, BatchController.getAvailableConnections);
/**
* GET /api/batch-configs/connections/:type/tables
* DB
*/
router.get("/connections/:type/tables", authenticateToken, BatchController.getTablesFromConnection);
/**
* GET /api/batch-configs/connections/:type/:id/tables
* DB
*/
router.get("/connections/:type/:id/tables", authenticateToken, BatchController.getTablesFromConnection);
/**
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
* DB
*/
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
/**
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
* DB
*/
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
/**
* GET /api/batch-configs/:id
*
*/
router.get("/:id", authenticateToken, BatchController.getBatchConfigById);
/**
* POST /api/batch-configs
*
*/
router.post("/", authenticateToken, BatchController.createBatchConfig);
/**
* PUT /api/batch-configs/:id
*
*/
router.put("/:id", authenticateToken, BatchController.updateBatchConfig);
/**
* DELETE /api/batch-configs/:id
* ( )
*/
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
export default router;

View File

@ -0,0 +1,550 @@
// 배치관리 서비스
// 작성일: 2024-12-24
import { PrismaClient } from "@prisma/client";
import {
BatchConfig,
BatchMapping,
BatchConfigFilter,
BatchMappingRequest,
BatchValidationResult,
ApiResponse,
ConnectionInfo,
TableInfo,
ColumnInfo,
} from "../types/batchTypes";
import { ExternalDbConnectionService } from "./externalDbConnectionService";
import { DbConnectionManager } from "./dbConnectionManager";
const prisma = new PrismaClient();
export class BatchService {
/**
*
*/
static async getBatchConfigs(
filter: BatchConfigFilter
): Promise<ApiResponse<BatchConfig[]>> {
try {
const where: any = {};
// 필터 조건 적용
if (filter.is_active) {
where.is_active = filter.is_active;
}
if (filter.company_code) {
where.company_code = filter.company_code;
}
// 검색 조건 적용
if (filter.search && filter.search.trim()) {
where.OR = [
{
batch_name: {
contains: filter.search.trim(),
mode: "insensitive",
},
},
{
description: {
contains: filter.search.trim(),
mode: "insensitive",
},
},
];
}
const batchConfigs = await prisma.batch_configs.findMany({
where,
include: {
batch_mappings: true,
},
orderBy: [{ is_active: "desc" }, { batch_name: "asc" }],
});
return {
success: true,
data: batchConfigs as BatchConfig[],
};
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
return {
success: false,
message: "배치 설정 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async getBatchConfigById(
id: number
): Promise<ApiResponse<BatchConfig>> {
try {
const batchConfig = await prisma.batch_configs.findUnique({
where: { id },
include: {
batch_mappings: {
orderBy: [
{ from_table_name: "asc" },
{ from_column_name: "asc" },
{ mapping_order: "asc" },
],
},
},
});
if (!batchConfig) {
return {
success: false,
message: "배치 설정을 찾을 수 없습니다.",
};
}
return {
success: true,
data: batchConfig as BatchConfig,
};
} catch (error) {
console.error("배치 설정 조회 오류:", error);
return {
success: false,
message: "배치 설정 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async createBatchConfig(
data: BatchMappingRequest,
userId?: string
): Promise<ApiResponse<BatchConfig>> {
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,
description: data.description,
cron_schedule: data.cron_schedule,
created_by: userId,
updated_by: userId,
},
});
// 배치 매핑 생성
const mappings = await Promise.all(
data.mappings.map((mapping, index) =>
tx.batch_mappings.create({
data: {
batch_config_id: batchConfig.id,
from_connection_type: mapping.from_connection_type,
from_connection_id: mapping.from_connection_id,
from_table_name: mapping.from_table_name,
from_column_name: mapping.from_column_name,
from_column_type: mapping.from_column_type,
to_connection_type: mapping.to_connection_type,
to_connection_id: mapping.to_connection_id,
to_table_name: mapping.to_table_name,
to_column_name: mapping.to_column_name,
to_column_type: mapping.to_column_type,
mapping_order: mapping.mapping_order || index + 1,
created_by: userId,
},
})
)
);
return {
...batchConfig,
batch_mappings: mappings,
};
});
return {
success: true,
data: result as BatchConfig,
message: "배치 설정이 성공적으로 생성되었습니다.",
};
} catch (error) {
console.error("배치 설정 생성 오류:", error);
return {
success: false,
message: "배치 설정 생성에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async updateBatchConfig(
id: number,
data: Partial<BatchMappingRequest>,
userId?: string
): Promise<ApiResponse<BatchConfig>> {
try {
// 기존 배치 설정 확인
const existingConfig = await prisma.batch_configs.findUnique({
where: { id },
include: { batch_mappings: true },
});
if (!existingConfig) {
return {
success: false,
message: "배치 설정을 찾을 수 없습니다.",
};
}
// 매핑이 제공된 경우 유효성 검사
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) => {
// 배치 설정 업데이트
const updateData: any = {
updated_by: userId,
};
if (data.batch_name) updateData.batch_name = data.batch_name;
if (data.description !== undefined) updateData.description = data.description;
if (data.cron_schedule) updateData.cron_schedule = data.cron_schedule;
const batchConfig = await tx.batch_configs.update({
where: { id },
data: updateData,
});
// 매핑이 제공된 경우 기존 매핑 삭제 후 새로 생성
if (data.mappings) {
await tx.batch_mappings.deleteMany({
where: { batch_config_id: id },
});
const mappings = await Promise.all(
data.mappings.map((mapping, index) =>
tx.batch_mappings.create({
data: {
batch_config_id: id,
from_connection_type: mapping.from_connection_type,
from_connection_id: mapping.from_connection_id,
from_table_name: mapping.from_table_name,
from_column_name: mapping.from_column_name,
from_column_type: mapping.from_column_type,
to_connection_type: mapping.to_connection_type,
to_connection_id: mapping.to_connection_id,
to_table_name: mapping.to_table_name,
to_column_name: mapping.to_column_name,
to_column_type: mapping.to_column_type,
mapping_order: mapping.mapping_order || index + 1,
created_by: userId,
},
})
)
);
return {
...batchConfig,
batch_mappings: mappings,
};
} else {
return {
...batchConfig,
batch_mappings: existingConfig.batch_mappings,
};
}
});
return {
success: true,
data: result as BatchConfig,
message: "배치 설정이 성공적으로 수정되었습니다.",
};
} catch (error) {
console.error("배치 설정 수정 오류:", error);
return {
success: false,
message: "배치 설정 수정에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
* ( )
*/
static async deleteBatchConfig(
id: number,
userId?: string
): Promise<ApiResponse<void>> {
try {
const existingConfig = await prisma.batch_configs.findUnique({
where: { id },
});
if (!existingConfig) {
return {
success: false,
message: "배치 설정을 찾을 수 없습니다.",
};
}
await prisma.batch_configs.update({
where: { id },
data: {
is_active: "N",
updated_by: userId,
},
});
return {
success: true,
message: "배치 설정이 성공적으로 삭제되었습니다.",
};
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
return {
success: false,
message: "배치 설정 삭제에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async getAvailableConnections(): Promise<ApiResponse<ConnectionInfo[]>> {
try {
const connections: ConnectionInfo[] = [];
// 내부 DB 추가
connections.push({
type: 'internal',
name: 'Internal Database',
db_type: 'postgresql',
});
// 외부 DB 연결 조회
const externalConnections = await ExternalDbConnectionService.getConnections({
is_active: 'Y',
});
if (externalConnections.success && externalConnections.data) {
externalConnections.data.forEach((conn) => {
connections.push({
type: 'external',
id: conn.id,
name: conn.connection_name,
db_type: conn.db_type,
});
});
}
return {
success: true,
data: connections,
};
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
return {
success: false,
message: "커넥션 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
): Promise<ApiResponse<string[]>> {
try {
let tables: string[] = [];
if (connectionType === 'internal') {
// 내부 DB 테이블 조회
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`;
tables = result.map(row => row.table_name);
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 테이블 조회
const tablesResult = await ExternalDbConnectionService.getTables(connectionId);
if (tablesResult.success && tablesResult.data) {
tables = tablesResult.data;
}
}
return {
success: true,
data: tables,
};
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
return {
success: false,
message: "테이블 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
tableName: string,
connectionId?: number
): Promise<ApiResponse<ColumnInfo[]>> {
try {
let columns: ColumnInfo[] = [];
if (connectionType === 'internal') {
// 내부 DB 컬럼 조회
const result = await prisma.$queryRaw<Array<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}>>`
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
`;
columns = result.map(row => ({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable === 'YES',
column_default: row.column_default,
}));
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 컬럼 조회
const columnsResult = await ExternalDbConnectionService.getTableColumns(
connectionId,
tableName
);
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,
}));
}
}
return {
success: true,
data: columns,
};
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return {
success: false,
message: "컬럼 정보 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
*
*/
private static async validateBatchMappings(
mappings: BatchMapping[]
): Promise<BatchValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
if (!mappings || mappings.length === 0) {
errors.push("최소 하나 이상의 매핑이 필요합니다.");
return { isValid: false, errors, warnings };
}
// n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지)
const toMappings = new Map<string, number>();
mappings.forEach((mapping, index) => {
const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
if (toMappings.has(toKey)) {
errors.push(
`매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.`
);
} else {
toMappings.set(toKey, index);
}
});
// 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑)
const fromMappings = new Map<string, number[]>();
mappings.forEach((mapping, index) => {
const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}:${mapping.from_column_name}`;
if (!fromMappings.has(fromKey)) {
fromMappings.set(fromKey, []);
}
fromMappings.get(fromKey)!.push(index);
});
fromMappings.forEach((indices, fromKey) => {
if (indices.length > 1) {
const [, , tableName, columnName] = fromKey.split(':');
warnings.push(
`FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)`
);
}
});
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
}

View File

@ -0,0 +1,85 @@
// 배치관리 타입 정의
// 작성일: 2024-12-24
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
// TO 정보
to_connection_type: 'internal' | 'external';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface TableInfo {
table_name: string;
columns: ColumnInfo[];
}
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: boolean;
column_default?: string;
}
export interface BatchMappingRequest {
batch_name: string;
description?: string;
cron_schedule: string;
mappings: BatchMapping[];
}
export interface BatchValidationResult {
isValid: boolean;
errors: string[];
warnings?: string[];
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}

585
docs/batch.html Normal file
View File

@ -0,0 +1,585 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>배치관리 매핑 시스템</title>
<style>
body {
font-family: 'Malgun Gothic', Arial, sans-serif;
margin: 20px;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.main-container {
background: white;
border-radius: 8px;
max-width: 1400px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
font-size: 24px;
font-weight: bold;
}
.input-section {
padding: 20px;
background-color: #f8f9fa;
border-bottom: 2px solid #e9ecef;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057;
}
.input-group input, .input-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.input-group textarea {
height: 60px;
resize: vertical;
}
.mapping-container {
display: flex;
padding: 20px;
gap: 20px;
min-height: 500px;
}
.db-section {
flex: 1;
border: 2px solid #dee2e6;
border-radius: 8px;
background: white;
}
.db-header {
background-color: #007bff;
color: white;
padding: 15px;
font-weight: bold;
text-align: center;
font-size: 18px;
}
.from-section .db-header {
background-color: #28a745;
}
.to-section .db-header {
background-color: #dc3545;
}
.selection-area {
padding: 20px;
}
.select-group {
margin-bottom: 20px;
}
.select-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #495057;
}
.select-group select {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background-color: white;
}
.columns-area {
margin-top: 20px;
min-height: 200px;
}
.table-info {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
}
.table-name {
font-weight: bold;
color: #007bff;
margin-bottom: 10px;
font-size: 16px;
}
.column-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.column-item {
padding: 10px 15px;
background-color: white;
border: 2px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.column-item:hover {
border-color: #007bff;
box-shadow: 0 2px 4px rgba(0,123,255,0.2);
}
.column-item.selected {
border-color: #007bff;
background-color: #e3f2fd;
font-weight: bold;
}
.column-item.mapped {
border-color: #28a745;
background-color: #d4edda;
}
.column-type {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
.mapping-display {
margin-top: 20px;
padding: 15px;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
}
.mapping-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #dee2e6;
}
.mapping-item:last-child {
border-bottom: none;
}
.mapping-arrow {
color: #007bff;
font-weight: bold;
margin: 0 10px;
}
.remove-mapping {
background-color: #dc3545;
color: white;
border: none;
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
}
.save-button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.save-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.instruction {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
font-size: 14px;
color: #0c5460;
}
</style>
</head>
<body>
<div class="main-container">
<div class="header">
배치관리 매핑 시스템
</div>
<div class="input-section">
<div class="input-group">
<label for="cronSchedule">실행주기 (크론탭 형식)</label>
<input type="text" id="cronSchedule" placeholder="예: 0 12 * * * (매일 12시)" value="1 11 3 * *">
</div>
<div class="input-group">
<label for="description">비고</label>
<textarea id="description" placeholder="하루한번 12시에 실행하는 인사정보 배치 등등...">하루한번 12시에 실행하는 인사정보 배치</textarea>
</div>
</div>
<div class="mapping-container">
<div class="db-section from-section">
<div class="db-header">FROM (원본 데이터베이스)</div>
<div class="selection-area">
<div class="instruction">
1단계: 컨넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
</div>
<div class="select-group">
<label for="fromConnection">컨넥션 선택</label>
<select id="fromConnection">
<option value="">컨넥션을 선택하세요</option>
<option value="oracle_db">Oracle_DB</option>
<option value="mes_db">MES_DB</option>
<option value="plm_db">PLM_DB</option>
<option value="erp_db">ERP_DB</option>
</select>
</div>
<div class="select-group">
<label for="fromTable">테이블 선택</label>
<select id="fromTable" disabled>
<option value="">먼저 컨넥션을 선택하세요</option>
</select>
</div>
<div class="columns-area" id="fromColumns">
<!-- 동적으로 컬럼들이 표시될 영역 -->
</div>
</div>
</div>
<div class="db-section to-section">
<div class="db-header">TO (대상 데이터베이스)</div>
<div class="selection-area">
<div class="instruction">
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
</div>
<div class="select-group">
<label for="toConnection">컨넥션 선택</label>
<select id="toConnection">
<option value="">컨넥션을 선택하세요</option>
<option value="oracle_db">Oracle_DB</option>
<option value="mes_db">MES_DB</option>
<option value="plm_db">PLM_DB</option>
<option value="erp_db">ERP_DB</option>
</select>
</div>
<div class="select-group">
<label for="toTable">테이블 선택</label>
<select id="toTable" disabled>
<option value="">먼저 컨넥션을 선택하세요</option>
</select>
</div>
<div class="columns-area" id="toColumns">
<!-- 동적으로 컬럼들이 표시될 영역 -->
</div>
</div>
</div>
</div>
<div class="mapping-display" id="mappingDisplay" style="margin: 20px; display: none;">
<h4>컬럼 매핑 현황</h4>
<div id="mappingList">
<!-- 매핑된 컬럼들이 표시될 영역 -->
</div>
</div>
<button class="save-button" onclick="saveMapping()">
배치 매핑 저장
</button>
</div>
<script>
// 샘플 데이터 - 실제로는 서버에서 가져올 데이터
const sampleData = {
oracle_db: {
employee: [
{name: 'user_id', type: 'VARCHAR2(20)'},
{name: 'user_name', type: 'VARCHAR2(100)'},
{name: 'department', type: 'VARCHAR2(50)'},
{name: 'email', type: 'VARCHAR2(200)'},
{name: 'created_date', type: 'DATE'}
],
department: [
{name: 'dept_id', type: 'VARCHAR2(10)'},
{name: 'dept_name', type: 'VARCHAR2(100)'},
{name: 'manager_id', type: 'VARCHAR2(20)'}
]
},
mes_db: {
user_info: [
{name: 'user_id', type: 'VARCHAR(20)'},
{name: 'user_name', type: 'VARCHAR(100)'},
{name: 'position', type: 'VARCHAR(50)'},
{name: 'phone', type: 'VARCHAR(20)'},
{name: 'hire_date', type: 'DATETIME'}
],
project: [
{name: 'project_id', type: 'VARCHAR(20)'},
{name: 'project_name', type: 'VARCHAR(200)'},
{name: 'start_date', type: 'DATETIME'},
{name: 'end_date', type: 'DATETIME'}
]
},
plm_db: {
product: [
{name: 'product_id', type: 'VARCHAR(30)'},
{name: 'product_name', type: 'VARCHAR(200)'},
{name: 'category', type: 'VARCHAR(50)'},
{name: 'price', type: 'DECIMAL(10,2)'}
]
},
erp_db: {
customer: [
{name: 'customer_id', type: 'VARCHAR(20)'},
{name: 'customer_name', type: 'VARCHAR(200)'},
{name: 'address', type: 'TEXT'},
{name: 'contact', type: 'VARCHAR(100)'}
]
}
};
let selectedFromColumn = null;
let mappings = [];
// 컨넥션 선택 이벤트 처리
document.getElementById('fromConnection').addEventListener('change', function() {
loadTables('from', this.value);
});
document.getElementById('toConnection').addEventListener('change', function() {
loadTables('to', this.value);
});
// 테이블 선택 이벤트 처리
document.getElementById('fromTable').addEventListener('change', function() {
loadColumns('from', document.getElementById('fromConnection').value, this.value);
});
document.getElementById('toTable').addEventListener('change', function() {
loadColumns('to', document.getElementById('toConnection').value, this.value);
});
// 테이블 목록 로드
function loadTables(side, connectionValue) {
const tableSelect = document.getElementById(side + 'Table');
tableSelect.innerHTML = '<option value="">테이블을 선택하세요</option>';
tableSelect.disabled = false;
if (connectionValue && sampleData[connectionValue]) {
Object.keys(sampleData[connectionValue]).forEach(tableName => {
const option = document.createElement('option');
option.value = tableName;
option.textContent = tableName.toUpperCase();
tableSelect.appendChild(option);
});
}
// 컬럼 영역 초기화
document.getElementById(side + 'Columns').innerHTML = '';
}
// 컬럼 목록 로드
function loadColumns(side, connectionValue, tableName) {
const columnsArea = document.getElementById(side + 'Columns');
if (!connectionValue || !tableName || !sampleData[connectionValue] || !sampleData[connectionValue][tableName]) {
columnsArea.innerHTML = '';
return;
}
const columns = sampleData[connectionValue][tableName];
columnsArea.innerHTML = `
<div class="table-info">
<div class="table-name">${tableName.toUpperCase()} 테이블</div>
<div class="column-list">
${columns.map(col => `
<div class="column-item" onclick="handleColumnClick('${side}', '${connectionValue}', '${tableName}', '${col.name}', '${col.type}')">
<div>${col.name}</div>
<div class="column-type">${col.type}</div>
</div>
`).join('')}
</div>
</div>
`;
}
// 컬럼 클릭 처리
function handleColumnClick(side, connection, table, columnName, columnType) {
if (side === 'from') {
// FROM 컬럼 선택
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
item.classList.remove('selected');
});
event.target.closest('.column-item').classList.add('selected');
selectedFromColumn = {
side: 'from',
connection: connection,
table: table,
column: columnName,
type: columnType
};
} else if (side === 'to' && selectedFromColumn) {
// TO 컬럼 선택하여 매핑 생성
const mapping = {
from: selectedFromColumn,
to: {
side: 'to',
connection: connection,
table: table,
column: columnName,
type: columnType
}
};
// 중복 매핑 체크
const existingMapping = mappings.find(m =>
m.from.column === mapping.from.column &&
m.to.column === mapping.to.column
);
if (!existingMapping) {
mappings.push(mapping);
updateMappingDisplay();
updateColumnStyles();
}
// FROM 선택 해제
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
item.classList.remove('selected');
});
selectedFromColumn = null;
}
}
// 매핑 표시 업데이트
function updateMappingDisplay() {
const mappingDisplay = document.getElementById('mappingDisplay');
const mappingList = document.getElementById('mappingList');
if (mappings.length === 0) {
mappingDisplay.style.display = 'none';
return;
}
mappingDisplay.style.display = 'block';
mappingList.innerHTML = mappings.map((mapping, index) => `
<div class="mapping-item">
<span>${mapping.from.table}.${mapping.from.column} (${mapping.from.type})</span>
<span class="mapping-arrow"></span>
<span>${mapping.to.table}.${mapping.to.column} (${mapping.to.type})</span>
<button class="remove-mapping" onclick="removeMapping(${index})">삭제</button>
</div>
`).join('');
}
// 컬럼 스타일 업데이트
function updateColumnStyles() {
// 모든 컬럼 아이템에서 mapped 클래스 제거
document.querySelectorAll('.column-item').forEach(item => {
item.classList.remove('mapped');
});
// 매핑된 컬럼들에 스타일 적용
mappings.forEach(mapping => {
const fromColumns = document.querySelectorAll('#fromColumns .column-item');
const toColumns = document.querySelectorAll('#toColumns .column-item');
fromColumns.forEach(item => {
if (item.textContent.includes(mapping.from.column)) {
item.classList.add('mapped');
}
});
toColumns.forEach(item => {
if (item.textContent.includes(mapping.to.column)) {
item.classList.add('mapped');
}
});
});
}
// 매핑 삭제
function removeMapping(index) {
mappings.splice(index, 1);
updateMappingDisplay();
updateColumnStyles();
}
// 매핑 저장
function saveMapping() {
const cronSchedule = document.getElementById('cronSchedule').value;
const description = document.getElementById('description').value;
if (!cronSchedule) {
alert('실행주기를 입력해주세요.');
return;
}
if (mappings.length === 0) {
alert('최소 하나 이상의 컬럼 매핑을 설정해주세요.');
return;
}
const batchConfig = {
cronSchedule: cronSchedule,
description: description,
mappings: mappings,
createdAt: new Date().toISOString()
};
// 실제로는 서버로 전송
console.log('저장될 배치 설정:', batchConfig);
alert('배치 매핑이 성공적으로 저장되었습니다!\n\n' +
`실행주기: ${cronSchedule}\n` +
`매핑 개수: ${mappings.length}개\n` +
`설명: ${description}`);
}
</script>
</body>
</html>

View File

@ -0,0 +1,579 @@
"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 {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
ColumnInfo,
BatchMappingRequest,
} from "@/lib/api/batch";
interface MappingState {
from: {
connection: ConnectionInfo | null;
table: string;
column: ColumnInfo | null;
} | null;
to: {
connection: ConnectionInfo | null;
table: string;
column: ColumnInfo | null;
} | null;
}
export default function BatchManagementPage() {
// 기본 상태
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
// 커넥션 및 테이블 데이터
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
const [fromTables, setFromTables] = useState<string[]>([]);
const [toTables, setToTables] = useState<string[]>([]);
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
// 선택된 상태
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
const [fromTable, setFromTable] = useState("");
const [toTable, setToTable] = useState("");
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
// 매핑 상태
const [mappings, setMappings] = useState<BatchMapping[]>([]);
const [loading, setLoading] = useState(false);
// 초기 데이터 로드
useEffect(() => {
loadConnections();
}, []);
// 커넥션 목록 로드
const loadConnections = async () => {
try {
const data = await BatchAPI.getAvailableConnections();
setConnections(data);
} catch (error) {
console.error("커넥션 목록 로드 오류:", error);
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
}
};
// FROM 커넥션 변경 시 테이블 로드
const handleFromConnectionChange = async (connectionId: string) => {
const connection = connections.find((c: ConnectionInfo) =>
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
);
if (!connection) return;
setFromConnection(connection);
setFromTable("");
setFromColumns([]);
setSelectedFromColumn(null);
try {
const tables = await BatchAPI.getTablesFromConnection(
connection.type,
connection.id
);
setFromTables(tables);
} catch (error) {
console.error("FROM 테이블 목록 로드 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
// TO 커넥션 변경 시 테이블 로드
const handleToConnectionChange = async (connectionId: string) => {
const connection = connections.find((c: ConnectionInfo) =>
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
);
if (!connection) return;
setToConnection(connection);
setToTable("");
setToColumns([]);
try {
const tables = await BatchAPI.getTablesFromConnection(
connection.type,
connection.id
);
setToTables(tables);
} catch (error) {
console.error("TO 테이블 목록 로드 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
// FROM 테이블 변경 시 컬럼 로드
const handleFromTableChange = async (tableName: string) => {
if (!fromConnection) return;
setFromTable(tableName);
setSelectedFromColumn(null);
try {
const columns = await BatchAPI.getTableColumns(
fromConnection.type,
tableName,
fromConnection.id
);
setFromColumns(columns);
} catch (error) {
console.error("FROM 컬럼 목록 로드 오류:", error);
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
}
};
// TO 테이블 변경 시 컬럼 로드
const handleToTableChange = async (tableName: string) => {
if (!toConnection) return;
setToTable(tableName);
try {
const columns = await BatchAPI.getTableColumns(
toConnection.type,
tableName,
toConnection.id
);
setToColumns(columns);
} catch (error) {
console.error("TO 컬럼 목록 로드 오류:", error);
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
}
};
// FROM 컬럼 선택
const handleFromColumnClick = (column: ColumnInfo) => {
setSelectedFromColumn(column);
};
// TO 컬럼 클릭으로 매핑 생성
const handleToColumnClick = (column: ColumnInfo) => {
if (!selectedFromColumn || !fromConnection || !toConnection) {
toast.error("먼저 FROM 컬럼을 선택해주세요.");
return;
}
// n:1 매핑 검사 (같은 TO 컬럼에 여러 FROM이 매핑되는 것 방지)
const existingToMapping = mappings.find((m: BatchMapping) =>
m.to_connection_type === toConnection.type &&
m.to_connection_id === toConnection.id &&
m.to_table_name === toTable &&
m.to_column_name === column.column_name
);
if (existingToMapping) {
toast.error("해당 TO 컬럼에는 이미 매핑이 존재합니다. n:1 매핑은 허용되지 않습니다.");
return;
}
// 새 매핑 생성
const newMapping: BatchMapping = {
from_connection_type: fromConnection.type,
from_connection_id: fromConnection.id,
from_table_name: fromTable,
from_column_name: selectedFromColumn.column_name,
from_column_type: selectedFromColumn.data_type,
to_connection_type: toConnection.type,
to_connection_id: toConnection.id,
to_table_name: toTable,
to_column_name: column.column_name,
to_column_type: column.data_type,
mapping_order: mappings.length + 1,
};
setMappings([...mappings, newMapping]);
setSelectedFromColumn(null);
toast.success("매핑이 추가되었습니다.");
};
// 매핑 삭제
const removeMapping = (index: number) => {
const newMappings = mappings.filter((_: BatchMapping, i: number) => i !== index);
setMappings(newMappings);
toast.success("매핑이 삭제되었습니다.");
};
// 배치 설정 저장
const saveBatchConfig = async () => {
if (!batchName.trim()) {
toast.error("배치명을 입력해주세요.");
return;
}
if (!cronSchedule.trim()) {
toast.error("실행주기를 입력해주세요.");
return;
}
if (mappings.length === 0) {
toast.error("최소 하나 이상의 컬럼 매핑을 설정해주세요.");
return;
}
setLoading(true);
try {
const request: BatchMappingRequest = {
batch_name: batchName,
description: description || undefined,
cron_schedule: cronSchedule,
mappings: mappings,
};
await BatchAPI.createBatchConfig(request);
toast.success("배치 설정이 성공적으로 저장되었습니다!");
// 폼 초기화
setBatchName("");
setDescription("");
setCronSchedule("0 12 * * *");
setMappings([]);
setFromConnection(null);
setToConnection(null);
setFromTable("");
setToTable("");
setFromTables([]);
setToTables([]);
setFromColumns([]);
setToColumns([]);
setSelectedFromColumn(null);
} catch (error) {
console.error("배치 설정 저장 오류:", error);
toast.error("배치 설정 저장에 실패했습니다.");
} finally {
setLoading(false);
}
};
// 컬럼이 매핑되었는지 확인
const isColumnMapped = (
connectionType: 'internal' | 'external',
connectionId: number | undefined,
tableName: string,
columnName: string,
side: 'from' | 'to'
) => {
return mappings.some((mapping: BatchMapping) => {
if (side === 'from') {
return mapping.from_connection_type === connectionType &&
mapping.from_connection_id === connectionId &&
mapping.from_table_name === tableName &&
mapping.from_column_name === columnName;
} else {
return mapping.to_connection_type === connectionType &&
mapping.to_connection_id === connectionId &&
mapping.to_table_name === tableName &&
mapping.to_column_name === columnName;
}
});
};
return (
<div className="container mx-auto p-6 max-w-7xl">
<Card className="mb-6">
<CardHeader className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<CardTitle className="text-2xl font-bold text-center">
</CardTitle>
</CardHeader>
</Card>
{/* 기본 설정 섹션 */}
<Card className="mb-6">
<CardHeader className="bg-gray-50">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBatchName(e.target.value)}
placeholder="예: 인사정보 동기화 배치"
/>
</div>
<div>
<Label htmlFor="cronSchedule"> ( ) *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCronSchedule(e.target.value)}
placeholder="예: 0 12 * * * (매일 12시)"
/>
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요..."
rows={3}
/>
</div>
</CardContent>
</Card>
{/* 매핑 설정 섹션 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* FROM 섹션 */}
<Card>
<CardHeader className="bg-green-500 text-white">
<CardTitle>FROM ( )</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
1단계: 커넥션 2단계: 테이블 3단계: 컬럼
</div>
<div className="space-y-4">
<div>
<Label> </Label>
<Select onValueChange={handleFromConnectionChange}>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{connections.map((conn: ConnectionInfo) => (
<SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id}
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
>
{conn.name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Select
value={fromTable}
onValueChange={handleFromTableChange}
disabled={!fromConnection}
>
<SelectTrigger>
<SelectValue placeholder={fromConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{fromTables.map((table: string) => (
<SelectItem key={table} value={table}>
{table.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{fromTable && fromColumns.length > 0 && (
<div>
<div className="bg-gray-50 border rounded p-4">
<h4 className="font-semibold text-blue-600 mb-3">
{fromTable.toUpperCase()}
</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{fromColumns.map((column: ColumnInfo) => (
<div
key={column.column_name}
className={`p-3 border-2 rounded cursor-pointer transition-all ${
selectedFromColumn?.column_name === column.column_name
? 'border-blue-500 bg-blue-50 font-semibold'
: isColumnMapped(
fromConnection!.type,
fromConnection!.id,
fromTable,
column.column_name,
'from'
)
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
}`}
onClick={() => handleFromColumnClick(column)}
>
<div>{column.column_name}</div>
<div className="text-xs text-gray-500 italic">
{column.data_type}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* TO 섹션 */}
<Card>
<CardHeader className="bg-red-500 text-white">
<CardTitle>TO ( )</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4 text-sm text-yellow-800">
FROM에서 ,
</div>
<div className="space-y-4">
<div>
<Label> </Label>
<Select onValueChange={handleToConnectionChange}>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{connections.map((conn: ConnectionInfo) => (
<SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id}
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
>
{conn.name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Select
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger>
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{toTables.map((table: string) => (
<SelectItem key={table} value={table}>
{table.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{toTable && toColumns.length > 0 && (
<div>
<div className="bg-gray-50 border rounded p-4">
<h4 className="font-semibold text-red-600 mb-3">
{toTable.toUpperCase()}
</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{toColumns.map((column: ColumnInfo) => (
<div
key={column.column_name}
className={`p-3 border-2 rounded cursor-pointer transition-all ${
isColumnMapped(
toConnection!.type,
toConnection!.id,
toTable,
column.column_name,
'to'
)
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white hover:border-red-300 hover:shadow-sm'
}`}
onClick={() => handleToColumnClick(column)}
>
<div>{column.column_name}</div>
<div className="text-xs text-gray-500 italic">
{column.data_type}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 매핑 현황 섹션 */}
{mappings.length > 0 && (
<Card className="mb-6">
<CardHeader className="bg-yellow-100 border-b">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-3">
{mappings.map((mapping: BatchMapping, index: number) => (
<div key={index} className="flex items-center justify-between p-3 border rounded bg-gray-50">
<div className="flex items-center space-x-4">
<span className="text-sm">
<Badge className="mr-2 border">FROM</Badge>
{mapping.from_table_name}.{mapping.from_column_name}
<span className="text-gray-500 ml-1">({mapping.from_column_type})</span>
</span>
<ArrowRight className="h-4 w-4 text-blue-500" />
<span className="text-sm">
<Badge className="mr-2 border">TO</Badge>
{mapping.to_table_name}.{mapping.to_column_name}
<span className="text-gray-500 ml-1">({mapping.to_column_type})</span>
</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => removeMapping(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 저장 버튼 */}
<Card>
<CardContent className="p-6">
<Button
onClick={saveBatchConfig}
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white py-3 text-lg font-semibold"
>
{loading ? (
<>
<RefreshCw className="mr-2 h-5 w-5 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-5 w-5" />
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}

276
frontend/lib/api/batch.ts Normal file
View File

@ -0,0 +1,276 @@
// 배치관리 API 클라이언트
// 작성일: 2024-12-24
import { apiClient } from "./client";
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
// TO 정보
to_connection_type: 'internal' | 'external';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: boolean;
column_default?: string;
}
export interface BatchMappingRequest {
batch_name: string;
description?: string;
cron_schedule: string;
mappings: BatchMapping[];
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export class BatchAPI {
private static readonly BASE_PATH = "/batch-configs";
/**
*
*/
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<BatchConfig[]> {
try {
const params = new URLSearchParams();
if (filter.is_active) params.append("is_active", filter.is_active);
if (filter.company_code) params.append("company_code", filter.company_code);
if (filter.search) params.append("search", filter.search);
const response = await apiClient.get<ApiResponse<BatchConfig[]>>(
`${this.BASE_PATH}?${params.toString()}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getBatchConfigById(id: number): Promise<BatchConfig> {
try {
const response = await apiClient.get<ApiResponse<BatchConfig>>(
`${this.BASE_PATH}/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정을 찾을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
try {
const response = await apiClient.post<ApiResponse<BatchConfig>>(
this.BASE_PATH,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 생성에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 생성 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 생성 오류:", error);
throw error;
}
}
/**
*
*/
static async updateBatchConfig(
id: number,
data: Partial<BatchMappingRequest>
): Promise<BatchConfig> {
try {
const response = await apiClient.put<ApiResponse<BatchConfig>>(
`${this.BASE_PATH}/${id}`,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 수정에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 수정 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 수정 오류:", error);
throw error;
}
}
/**
*
*/
static async deleteBatchConfig(id: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<void>>(
`${this.BASE_PATH}/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 삭제에 실패했습니다.");
}
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
throw error;
}
}
/**
*
*/
static async getAvailableConnections(): Promise<ConnectionInfo[]> {
try {
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
`${this.BASE_PATH}/connections`,
);
if (!response.data.success) {
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
): Promise<string[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
url += `/${connectionId}`;
}
url += '/tables';
const response = await apiClient.get<ApiResponse<string[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
tableName: string,
connectionId?: number
): Promise<ColumnInfo[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
url += `/${connectionId}`;
}
url += `/tables/${tableName}/columns`;
const response = await apiClient.get<ApiResponse<ColumnInfo[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
throw error;
}
}
}

View File

@ -12,6 +12,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsxImportSource": "react",
"incremental": true,
"plugins": [
{