From 4abf5b31c06fbcb0d15df222d2afdc9769a2776f Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 24 Sep 2025 10:46:55 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 382 ++++++++++-- backend-node/package.json | 4 +- backend-node/prisma/schema.prisma | 52 ++ backend-node/src/app.ts | 2 + .../src/controllers/batchController.ts | 312 ++++++++++ backend-node/src/routes/batchRoutes.ts | 70 +++ backend-node/src/services/batchService.ts | 550 ++++++++++++++++ backend-node/src/types/batchTypes.ts | 85 +++ docs/batch.html | 585 ++++++++++++++++++ frontend/app/(main)/admin/batchmng/page.tsx | 579 +++++++++++++++++ frontend/lib/api/batch.ts | 276 +++++++++ frontend/tsconfig.json | 1 + 12 files changed, 2857 insertions(+), 41 deletions(-) create mode 100644 backend-node/src/controllers/batchController.ts create mode 100644 backend-node/src/routes/batchRoutes.ts create mode 100644 backend-node/src/services/batchService.ts create mode 100644 backend-node/src/types/batchTypes.ts create mode 100644 docs/batch.html create mode 100644 frontend/app/(main)/admin/batchmng/page.tsx create mode 100644 frontend/lib/api/batch.ts diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 06920113..3655eb1b 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -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", diff --git a/backend-node/package.json b/backend-node/package.json index 7c7e9fb8..36db7314 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -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", diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index d7c9b38d..846b4452 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -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) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 2d75b3d5..566b8849 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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); diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts new file mode 100644 index 00000000..93ad3b16 --- /dev/null +++ b/backend-node/src/controllers/batchController.ts @@ -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 = 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 : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/routes/batchRoutes.ts b/backend-node/src/routes/batchRoutes.ts new file mode 100644 index 00000000..7788c4c9 --- /dev/null +++ b/backend-node/src/routes/batchRoutes.ts @@ -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; diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts new file mode 100644 index 00000000..df321a0d --- /dev/null +++ b/backend-node/src/services/batchService.ts @@ -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> { + 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> { + 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> { + 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, + userId?: string + ): Promise> { + 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> { + 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> { + 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> { + try { + let tables: string[] = []; + + 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 => 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> { + try { + let columns: ColumnInfo[] = []; + + if (connectionType === 'internal') { + // 내부 DB 컬럼 조회 + 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 + `; + + 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 { + 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(); + + 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(); + + 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, + }; + } +} diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts new file mode 100644 index 00000000..4a470eb5 --- /dev/null +++ b/backend-node/src/types/batchTypes.ts @@ -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 { + success: boolean; + data?: T; + message?: string; + error?: string; +} diff --git a/docs/batch.html b/docs/batch.html new file mode 100644 index 00000000..bc70b3cd --- /dev/null +++ b/docs/batch.html @@ -0,0 +1,585 @@ + + + + + + 배치관리 매핑 시스템 + + + +
+
+ 배치관리 매핑 시스템 +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
FROM (원본 데이터베이스)
+
+
+ 1단계: 컨넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요 +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+
TO (대상 데이터베이스)
+
+
+ FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다 +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + + + +
+ + + + \ No newline at end of file diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/batchmng/page.tsx new file mode 100644 index 00000000..7ad61dc6 --- /dev/null +++ b/frontend/app/(main)/admin/batchmng/page.tsx @@ -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([]); + 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([]); + 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 ( +
+ + + + 배치관리 매핑 시스템 + + + + + {/* 기본 설정 섹션 */} + + + 기본 설정 + + +
+
+ + ) => setBatchName(e.target.value)} + placeholder="예: 인사정보 동기화 배치" + /> +
+
+ + ) => setCronSchedule(e.target.value)} + placeholder="예: 0 12 * * * (매일 12시)" + /> +
+
+
+ +