Compare commits
2009 Commits
feature/ba
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
147d187901 | |
|
|
d09a6977f7 | |
|
|
faf4100056 | |
|
|
410b4a7b14 | |
|
|
e4667cce5f | |
|
|
c282d5c611 | |
|
|
d4afc06f4a | |
|
|
f2ab4f11bd | |
|
|
514d852fa6 | |
|
|
8603fddbcb | |
|
|
58adc0a100 | |
|
|
0382c94d73 | |
|
|
49f67451eb | |
|
|
e3852aca5d | |
|
|
df8065503d | |
|
|
0a85146564 | |
|
|
ad3b853d04 | |
|
|
2a3cc7ba00 | |
|
|
ee273c5103 | |
|
|
50a25cb9de | |
|
|
d1631d15ff | |
|
|
a020985630 | |
|
|
351ecbb35d | |
|
|
d32e933c03 | |
|
|
4497985104 | |
|
|
b97b0cc7d7 | |
|
|
160ad87395 | |
|
|
4972f26cee | |
|
|
02eee979ea | |
|
|
08de1372c5 | |
|
|
ab52c49492 | |
|
|
8a865ac1f4 | |
|
|
0a89cc2fb0 | |
|
|
ab3a493abb | |
|
|
ac0f461832 | |
|
|
c2256de8ec | |
|
|
484c98da9e | |
|
|
b2dc06d0f2 | |
|
|
efa95af4b9 | |
|
|
e8bdcbb95c | |
|
|
60ae073606 | |
|
|
a36802ab10 | |
|
|
98c489ee22 | |
|
|
c77c6290d3 | |
|
|
9dc549be09 | |
|
|
40a226ca30 | |
|
|
5d89b69451 | |
|
|
7fd3364aef | |
|
|
2326c3548b | |
|
|
220ce57be1 | |
|
|
0ac83b1551 | |
|
|
3f474ecddd | |
|
|
ddf5ed4006 | |
|
|
c4ee084a1d | |
|
|
2e02ace388 | |
|
|
435eb90763 | |
|
|
98870b3348 | |
|
|
b7b750d134 | |
|
|
ac334db0b1 | |
|
|
16c9c71a23 | |
|
|
059ea6b30a | |
|
|
14f8714ea1 | |
|
|
a27cb85007 | |
|
|
b5d2195cd5 | |
|
|
0a3d42f3ad | |
|
|
b5c2e85496 | |
|
|
f321aaf7aa | |
|
|
26bb93ab6e | |
|
|
f9575d7b5f | |
|
|
c26b346054 | |
|
|
24315215de | |
|
|
ca73685bc2 | |
|
|
61a7f585b4 | |
|
|
cf97db7fbf | |
|
|
18b5161398 | |
|
|
b576837f18 | |
|
|
ef27e0e38f | |
|
|
d7d7dabe84 | |
|
|
d22fd078be | |
|
|
28fe908704 | |
|
|
1b5ae5fe1c | |
|
|
905a9f62c3 | |
|
|
989b7e53a7 | |
|
|
20e144af36 | |
|
|
e2a22bb853 | |
|
|
0deb466557 | |
|
|
f64279d084 | |
|
|
c74e97d66e | |
|
|
0beb8b20a3 | |
|
|
054da65a26 | |
|
|
75e6c9eb1a | |
|
|
0f2d0bb053 | |
|
|
306de370f1 | |
|
|
b6fefe2ebd | |
|
|
f799402564 | |
|
|
033f5eaf7e | |
|
|
d094b58ebf | |
|
|
3fa57ad2ae | |
|
|
821955cfac | |
|
|
b358a46c33 | |
|
|
b2add92abf | |
|
|
c2836a0209 | |
|
|
472fc8633c | |
|
|
4801ee5ca4 | |
|
|
87189c792e | |
|
|
9cc5bbbf05 | |
|
|
5f991db9c4 | |
|
|
9e7253a293 | |
|
|
31e87e0bca | |
|
|
0773989c74 | |
|
|
6732e7d969 | |
|
|
35f83c1937 | |
|
|
8aa6008351 | |
|
|
47b61a9a35 | |
|
|
d22c2ec96e | |
|
|
3677c77da0 | |
|
|
c11e80a43c | |
|
|
f8fb7d687e | |
|
|
a6569909a2 | |
|
|
5c9dda6826 | |
|
|
bcf512d2b5 | |
|
|
4d41cb40b6 | |
|
|
bf74dd0f92 | |
|
|
85ae1c1521 | |
|
|
38455325dd | |
|
|
f493f8ac80 | |
|
|
7fc341bca8 | |
|
|
ba2a281245 | |
|
|
aa0698556e | |
|
|
c76123a927 | |
|
|
ba20a2bf42 | |
|
|
23c9604672 | |
|
|
64c6942de3 | |
|
|
f07448ac17 | |
|
|
d49883d25f | |
|
|
217e390fe9 | |
|
|
363ef44586 | |
|
|
48aa004a7f | |
|
|
ee3a648917 | |
|
|
819a281df4 | |
|
|
dd1d3bb44d | |
|
|
52e6824e76 | |
|
|
80cf20e142 | |
|
|
abddb67a30 | |
|
|
a0a9253d2c | |
|
|
222a00b8a9 | |
|
|
e8516d9d6b | |
|
|
150a40e2a8 | |
|
|
cea3aa53ae | |
|
|
af4072cef1 | |
|
|
a50222e7d5 | |
|
|
69711f4e4b | |
|
|
2eccd1982c | |
|
|
0baffafac1 | |
|
|
910d070055 | |
|
|
8f4c95d20d | |
|
|
65e1c1a995 | |
|
|
d2c15d519d | |
|
|
583c6c8c79 | |
|
|
a52ab0b206 | |
|
|
551e893f15 | |
|
|
85f8637ce0 | |
|
|
b85b3cd578 | |
|
|
b8c8b31033 | |
|
|
0f57309d74 | |
|
|
4dfa82d3dd | |
|
|
34e48993e4 | |
|
|
9821afe9cd | |
|
|
38599a1bef | |
|
|
11e25694b9 | |
|
|
8928d851ca | |
|
|
3f81c449ad | |
|
|
00006bf2e2 | |
|
|
3e9bf29bcf | |
|
|
34ac1b0c42 | |
|
|
df94d73662 | |
|
|
dc449f6c69 | |
|
|
dcf3a63d9b | |
|
|
a3c83c834e | |
|
|
980c929d83 | |
|
|
a146667615 | |
|
|
2645d627da | |
|
|
f33d989202 | |
|
|
6a1343b847 | |
|
|
b61cb17aea | |
|
|
83eb92cb27 | |
|
|
5321ea5b80 | |
|
|
d90a403ed9 | |
|
|
c181385f11 | |
|
|
23ebae95d6 | |
|
|
17498b1b2b | |
|
|
384106dd95 | |
|
|
6f4c9b7fdd | |
|
|
26c61ee5b6 | |
|
|
d8ff49d1db | |
|
|
8c525673ab | |
|
|
47ac9ecd8a | |
|
|
1b633e55d2 | |
|
|
b279f8d58d | |
|
|
48e9840fa0 | |
|
|
62226918a7 | |
|
|
df70538027 | |
|
|
26020a29a0 | |
|
|
52df163fbb | |
|
|
777429af48 | |
|
|
856db80a36 | |
|
|
cd7adce874 | |
|
|
ca260aa260 | |
|
|
42d1a3fc5e | |
|
|
7c165a724e | |
|
|
0ce0860dcc | |
|
|
c6ff839e54 | |
|
|
e308fd0ccc | |
|
|
f2cb7d14ca | |
|
|
b5b229122b | |
|
|
126da9b46f | |
|
|
c365f06ed7 | |
|
|
563081fa1c | |
|
|
24331687d4 | |
|
|
ea848b97ee | |
|
|
15fc166683 | |
|
|
26fdab5b4e | |
|
|
12d3419b7f | |
|
|
a2b701a4bf | |
|
|
2213ad51b2 | |
|
|
7120d5edc3 | |
|
|
0eb005ce35 | |
|
|
4828488c72 | |
|
|
c1425be57f | |
|
|
25b7e637de | |
|
|
ad39374e54 | |
|
|
77bb917248 | |
|
|
6bf914d9b1 | |
|
|
e08c50c771 | |
|
|
0f027f2382 | |
|
|
09d574fb8a | |
|
|
6ae0778b4c | |
|
|
58b0e1b79b | |
|
|
f0322a49ad | |
|
|
5e27d21257 | |
|
|
efc9175fec | |
|
|
eb61506acd | |
|
|
75b5530d04 | |
|
|
cded99d644 | |
|
|
40fd5f9055 | |
|
|
0709b8df25 | |
|
|
6bfc1a97a3 | |
|
|
9ea0f1b84f | |
|
|
7cb8026979 | |
|
|
4f77c38207 | |
|
|
68017ed0e9 | |
|
|
338c885cfa | |
|
|
b3ee2b50e8 | |
|
|
64105bf525 | |
|
|
6925e3af3f | |
|
|
714698c20f | |
|
|
2a7066b6fd | |
|
|
5fbc76f85d | |
|
|
e747162058 | |
|
|
914f3d57f3 | |
|
|
12baad75c9 | |
|
|
85519e302f | |
|
|
98e96a1fb0 | |
|
|
76d7d5149b | |
|
|
239e4800c7 | |
|
|
6c75adb61d | |
|
|
7caf2dea94 | |
|
|
ad76bfe3b0 | |
|
|
4ad58ba942 | |
|
|
36a723b1a0 | |
|
|
5a94afc1d5 | |
|
|
2889e4c82c | |
|
|
c15ec8f7b9 | |
|
|
eb868965df | |
|
|
417e1d297b | |
|
|
0b1dc98e5c | |
|
|
fff10a1911 | |
|
|
2842930dba | |
|
|
5bdc903b0d | |
|
|
4ce0411809 | |
|
|
bd49db16c6 | |
|
|
113ef24bdf | |
|
|
7d6bff49aa | |
|
|
7b773f57b4 | |
|
|
58233e51de | |
|
|
4421ccaa71 | |
|
|
183f68e89a | |
|
|
fb82d2f5a1 | |
|
|
84a3956b02 | |
|
|
c78326bae1 | |
|
|
b45f4870e8 | |
|
|
fd58e9cce2 | |
|
|
c7efe8ec33 | |
|
|
3c4e251e9b | |
|
|
e902987e44 | |
|
|
54ca51258c | |
|
|
06d5069566 | |
|
|
e1d6c1740f | |
|
|
c32bd8a4bf | |
|
|
e040b94a62 | |
|
|
6476a83d86 | |
|
|
ea3b6d2083 | |
|
|
87caa4b3ca | |
|
|
e30b1cc01a | |
|
|
89ce2a9cd0 | |
|
|
ef991b3b26 | |
|
|
22e0ce1fc5 | |
|
|
00376202fd | |
|
|
6365ce4921 | |
|
|
47b23d1aa3 | |
|
|
f63399c1e1 | |
|
|
7ece757d3d | |
|
|
722b4787e2 | |
|
|
486e5ee29b | |
|
|
171ed6e938 | |
|
|
c20e393a1a | |
|
|
f300b637d1 | |
|
|
386ce629ac | |
|
|
a299195b42 | |
|
|
352f9f441f | |
|
|
aa283d11da | |
|
|
6bd25c8a9e | |
|
|
9878f1f502 | |
|
|
3396834417 | |
|
|
718788110a | |
|
|
ed2e0a1c6b | |
|
|
9fe22bc422 | |
|
|
859d68fff8 | |
|
|
a7edd74574 | |
|
|
755bbc0c58 | |
|
|
67471b2518 | |
|
|
542c0bae94 | |
|
|
82a7ff62ee | |
|
|
83f171189b | |
|
|
050a183c96 | |
|
|
e1567d3f77 | |
|
|
da195200a8 | |
|
|
c910572754 | |
|
|
4187ec0745 | |
|
|
73cc969bd8 | |
|
|
5f406fbe88 | |
|
|
533eaf5c9f | |
|
|
7875d8ab86 | |
|
|
e1a032933d | |
|
|
99c0960325 | |
|
|
ae6d917ec4 | |
|
|
5f26e998e3 | |
|
|
b85b888007 | |
|
|
9493d81903 | |
|
|
d7f015b37d | |
|
|
e8b581f5da | |
|
|
d90e68905e | |
|
|
f1c4891924 | |
|
|
002c71f9e8 | |
|
|
117912045f | |
|
|
6a4ebf362c | |
|
|
0decfe95de | |
|
|
2b912105a8 | |
|
|
acc867e38d | |
|
|
c5cb4336e5 | |
|
|
01778661ed | |
|
|
6fced32e29 | |
|
|
1cadafea0e | |
|
|
9162e3aa96 | |
|
|
79b3c19c68 | |
|
|
43ae8d1c49 | |
|
|
a8bc7983c0 | |
|
|
506a31df02 | |
|
|
8789b2b864 | |
|
|
8d34b73a45 | |
|
|
ea01309158 | |
|
|
45749c99c8 | |
|
|
43a6fb675f | |
|
|
961e7e9a14 | |
|
|
f38447be8e | |
|
|
a1b05b8982 | |
|
|
932eb288c6 | |
|
|
09f477172c | |
|
|
958624012d | |
|
|
483dbf8a1f | |
|
|
9fb94da493 | |
|
|
f1c775b691 | |
|
|
69754a31cb | |
|
|
9684a83f37 | |
|
|
2e7a215066 | |
|
|
228c497569 | |
|
|
01422e035b | |
|
|
adb21a5308 | |
|
|
228fd33a2a | |
|
|
c86140fad3 | |
|
|
9902b65598 | |
|
|
981ec27ed7 | |
|
|
849343ecfd | |
|
|
51c788cae8 | |
|
|
06d2cf7f72 | |
|
|
fdb9ef9167 | |
|
|
84efaed1eb | |
|
|
bdb70ce5b7 | |
|
|
8306d7961c | |
|
|
61ceab1a7b | |
|
|
90d136ca85 | |
|
|
da24db8f37 | |
|
|
a617c26721 | |
|
|
66bd21ee65 | |
|
|
1c6eb2ae61 | |
|
|
cf8a5a3d93 | |
|
|
a24654c867 | |
|
|
79c1a456f0 | |
|
|
ca86c0a10f | |
|
|
4e987f208a | |
|
|
bca6de9811 | |
|
|
f03b247db2 | |
|
|
176e9cf421 | |
|
|
7ca4eea5c1 | |
|
|
41442dccc2 | |
|
|
c5f24dc789 | |
|
|
ac8961160d | |
|
|
36bac321b8 | |
|
|
403bd0f8a1 | |
|
|
75e5326b3e | |
|
|
1fd428c016 | |
|
|
c3f066f88f | |
|
|
061fd45bc8 | |
|
|
f1a670ca9a | |
|
|
ff3c51c457 | |
|
|
0ed8e686c0 | |
|
|
0abe87ae1a | |
|
|
6c7807e1d1 | |
|
|
c6f0750050 | |
|
|
ffd31fc923 | |
|
|
7b30f6c7f2 | |
|
|
3589e4a5b9 | |
|
|
60b4bffdf9 | |
|
|
fb4b5b7e26 | |
|
|
8687c88f70 | |
|
|
6dcace3135 | |
|
|
b7b881ee86 | |
|
|
f47a0c770b | |
|
|
6f7a76febe | |
|
|
44f5265105 | |
|
|
e50ddd03d3 | |
|
|
ae38e0f249 | |
|
|
52db6fd43c | |
|
|
7acb4981b5 | |
|
|
5e0dae0aae | |
|
|
2e122b0703 | |
|
|
132cf4cd7d | |
|
|
0ec6d082d6 | |
|
|
0810debd2b | |
|
|
857e46eab6 | |
|
|
be916d3db7 | |
|
|
ccbbf46faf | |
|
|
1995c3dca4 | |
|
|
3d287bb883 | |
|
|
31746e8a0b | |
|
|
0832e7b6eb | |
|
|
00afa77d87 | |
|
|
d6f40f3cd3 | |
|
|
a73b37f558 | |
|
|
3a55ea3b64 | |
|
|
963e0c2d24 | |
|
|
f7e3c1924c | |
|
|
342042d761 | |
|
|
d8329d31e4 | |
|
|
56608001ff | |
|
|
4e74c7b5ba | |
|
|
b3e6613d66 | |
|
|
27b5f54a7c | |
|
|
eb56aec0a7 | |
|
|
270b97eec9 | |
|
|
9420b14836 | |
|
|
7688cb8078 | |
|
|
a2582a28e4 | |
|
|
4cff9e4cec | |
|
|
cee9903f94 | |
|
|
f6051e8bbd | |
|
|
cb38864ad8 | |
|
|
ffb59b4e1b | |
|
|
0ac5402b0b | |
|
|
8cc189da17 | |
|
|
8425dece7f | |
|
|
2f66fe1913 | |
|
|
109380b9e5 | |
|
|
a495088068 | |
|
|
54c674f3c9 | |
|
|
7f15861b6e | |
|
|
c1be1893f5 | |
|
|
a73f8ae7b3 | |
|
|
c52efddae9 | |
|
|
93443c98ee | |
|
|
6449eb5ac3 | |
|
|
3c73c20292 | |
|
|
16885225a0 | |
|
|
81c3e1b4ba | |
|
|
93b37e99e6 | |
|
|
f31fca0115 | |
|
|
c2d473bf59 | |
|
|
23a1dd6321 | |
|
|
4e10449b3f | |
|
|
d21c4acf0f | |
|
|
95cbd62b1a | |
|
|
f7384cb450 | |
|
|
665d1b51d8 | |
|
|
036380d267 | |
|
|
4777c2bc0a | |
|
|
b755f8f017 | |
|
|
e8bc770439 | |
|
|
8f6af5018c | |
|
|
76f6bd7f27 | |
|
|
722718b7ed | |
|
|
7ad70462d5 | |
|
|
51099ba858 | |
|
|
11215e3316 | |
|
|
309d4be31d | |
|
|
add98673bb | |
|
|
3a6af2fb71 | |
|
|
c85841b59f | |
|
|
ac3de6ab07 | |
|
|
1680163c61 | |
|
|
a9135165d9 | |
|
|
0ee49b77ae | |
|
|
5ed80df2d4 | |
|
|
ab8b5a2c91 | |
|
|
e8af7ae4c6 | |
|
|
016b8f707b | |
|
|
038c5a0973 | |
|
|
88024b4e60 | |
|
|
fcc709684b | |
|
|
b208b0be34 | |
|
|
c2a6dbea3b | |
|
|
563acb7c00 | |
|
|
30361e0f45 | |
|
|
84bd1ce154 | |
|
|
a489e2c155 | |
|
|
6a676dcf5c | |
|
|
308c78b067 | |
|
|
3f1ecfab15 | |
|
|
ab9ddaa190 | |
|
|
b03132595c | |
|
|
e67b5f76a8 | |
|
|
1d97bcaa9f | |
|
|
d3b4c4c42e | |
|
|
f2b0ac8fd5 | |
|
|
a4cf11264d | |
|
|
215242b676 | |
|
|
190a677067 | |
|
|
4247c3bb70 | |
|
|
99fd8336a5 | |
|
|
c486a31787 | |
|
|
9463d8d0b6 | |
|
|
1bb6448b1f | |
|
|
011f0556d2 | |
|
|
f6e0e02ddf | |
|
|
84095ace3b | |
|
|
fc5ffb03b2 | |
|
|
0e60f11084 | |
|
|
2327dbe35c | |
|
|
b9ee860e71 | |
|
|
fcdaa68ddc | |
|
|
91f9bb9d12 | |
|
|
2b747a1030 | |
|
|
6735142db4 | |
|
|
c4e81dd740 | |
|
|
7725cd1e87 | |
|
|
d729d299a9 | |
|
|
d6c5b3418d | |
|
|
bccb8a6330 | |
|
|
288e553221 | |
|
|
5bbbd37553 | |
|
|
f272f0c4c7 | |
|
|
1a68ae792e | |
|
|
512e1e30d1 | |
|
|
ae6f022f88 | |
|
|
d09c8e0787 | |
|
|
088596480f | |
|
|
fa6c00b6be | |
|
|
e84764dc2b | |
|
|
bc10f2101a | |
|
|
65c1855eba | |
|
|
d0801e2ccc | |
|
|
3188bc0513 | |
|
|
08575c296e | |
|
|
48300146e6 | |
|
|
5cd3bc52e3 | |
|
|
6707e2afd2 | |
|
|
90db4756e8 | |
|
|
e6b8212d39 | |
|
|
d1c9aeca18 | |
|
|
f75c3e43ed | |
|
|
d7e96327a7 | |
|
|
9998045013 | |
|
|
c7ae04859d | |
|
|
c71b958a05 | |
|
|
c64c94c07b | |
|
|
28ca4f2088 | |
|
|
3608d9f9c3 | |
|
|
990f667481 | |
|
|
dde65a2d1e | |
|
|
d11ffb1b00 | |
|
|
93ec294be3 | |
|
|
ba817980f0 | |
|
|
74da2cd97f | |
|
|
36a7529da2 | |
|
|
893ae06f19 | |
|
|
8b200ba9f3 | |
|
|
94b371ca0f | |
|
|
5e97a3a5e9 | |
|
|
0f8817835e | |
|
|
a8cbc289f6 | |
|
|
d550959cb7 | |
|
|
1506389757 | |
|
|
ece7f21bd3 | |
|
|
1ee1287b8a | |
|
|
a2e99b30e6 | |
|
|
2479e3a0c4 | |
|
|
167c3cd26b | |
|
|
bb98e9319f | |
|
|
6de40bea0c | |
|
|
612b46236f | |
|
|
d7e03d6b83 | |
|
|
3cf99dbad9 | |
|
|
0aaab45329 | |
|
|
cf73ce6ebb | |
|
|
987120f13b | |
|
|
aa78c0c0cb | |
|
|
8d07458c94 | |
|
|
84f47a021b | |
|
|
469c8b2e57 | |
|
|
7ac6bbc2c6 | |
|
|
fa59235cd2 | |
|
|
d908de7f66 | |
|
|
531ba3ffdb | |
|
|
fb4c9574d3 | |
|
|
e53515481b | |
|
|
b15b6e21ea | |
|
|
37fb9a13f8 | |
|
|
a20712d48e | |
|
|
5b456765ad | |
|
|
33e7767f75 | |
|
|
2b055757e2 | |
|
|
92a7e0eb3a | |
|
|
a278ceca3f | |
|
|
5609e6353f | |
|
|
f0f6c42b3c | |
|
|
ad5c7f643c | |
|
|
0df53f46b3 | |
|
|
ed1626d391 | |
|
|
11a99a5c2e | |
|
|
0ffec7f443 | |
|
|
ab1308efe8 | |
|
|
ec65ad6b9e | |
|
|
3dc67dd60a | |
|
|
61c1f10495 | |
|
|
892278853c | |
|
|
a5055cae15 | |
|
|
de1fe9865a | |
|
|
274078ef2c | |
|
|
e05af3c6f9 | |
|
|
0c57609ee9 | |
|
|
f04a3e3505 | |
|
|
09d2d7573d | |
|
|
2cc0a7b309 | |
|
|
7f296afc17 | |
|
|
8ec5c987de | |
|
|
7a596cad3d | |
|
|
c98257a794 | |
|
|
4c4e7965d7 | |
|
|
c39794d1a7 | |
|
|
46ef858c1d | |
|
|
cbe5cb4607 | |
|
|
65227c5e03 | |
|
|
47552bc35c | |
|
|
417d77729d | |
|
|
e713f55442 | |
|
|
96321f502f | |
|
|
7a185ca1ed | |
|
|
1c329b5e0c | |
|
|
a866647506 | |
|
|
1a77a5b28a | |
|
|
8e3452a04f | |
|
|
6da1590430 | |
|
|
a1daa63dcc | |
|
|
8781e9c6c3 | |
|
|
d2bd623d9a | |
|
|
662956edd4 | |
|
|
ccf8bd3284 | |
|
|
9e956999c5 | |
|
|
7c06b98f86 | |
|
|
b6a7b4a93b | |
|
|
5d3b3ea76e | |
|
|
58ca340699 | |
|
|
16d30632a0 | |
|
|
5c12b9fa83 | |
|
|
c1400081c6 | |
|
|
0e4ecef336 | |
|
|
6c751eb489 | |
|
|
a38650692c | |
|
|
cd39b2fc4d | |
|
|
bc66f3bba1 | |
|
|
ef3b85f343 | |
|
|
93d9937343 | |
|
|
dfc83f6114 | |
|
|
2cddb42255 | |
|
|
a90ddac512 | |
|
|
127f4dc783 | |
|
|
40c43bab16 | |
|
|
7a2f80b646 | |
|
|
532c56f997 | |
|
|
3ab32820e9 | |
|
|
687a1d57b2 | |
|
|
dbf6cfc995 | |
|
|
4d9f010ac5 | |
|
|
0d1be47914 | |
|
|
c39409823c | |
|
|
714919ad64 | |
|
|
0320d30f2d | |
|
|
0cb8d2cbe1 | |
|
|
4569defecf | |
|
|
3ebc5ea557 | |
|
|
e9738ce67f | |
|
|
669717f656 | |
|
|
52ad67d44a | |
|
|
ca3d6bf8fb | |
|
|
8b3017224f | |
|
|
e8be871d69 | |
|
|
de8b643277 | |
|
|
cb0bbd1ff3 | |
|
|
294c61e0e3 | |
|
|
676ec16879 | |
|
|
cb9c90fcdb | |
|
|
6a0ff5582f | |
|
|
700623aa78 | |
|
|
2a72f89c8a | |
|
|
4e29f92268 | |
|
|
eb5ea411c9 | |
|
|
760f9b2d67 | |
|
|
8317af92cd | |
|
|
37705e4a24 | |
|
|
e83fbed71c | |
|
|
e33664015a | |
|
|
6982635acd | |
|
|
7713d4073c | |
|
|
3b875f20b1 | |
|
|
3a3ecde358 | |
|
|
ae7b21147b | |
|
|
faacd5402c | |
|
|
70c6da0527 | |
|
|
4c4906f6b3 | |
|
|
a4f0681f76 | |
|
|
10d81cb9bc | |
|
|
b286bc3c63 | |
|
|
8e257f36b2 | |
|
|
7417f0e398 | |
|
|
a75b615c3a | |
|
|
9078873240 | |
|
|
bc34cded95 | |
|
|
4787a8b177 | |
|
|
a42db5f15a | |
|
|
30e6595bf3 | |
|
|
2c447fd325 | |
|
|
436d604bb3 | |
|
|
650c5ef722 | |
|
|
0789eb2e20 | |
|
|
8c83db596d | |
|
|
cd47f569e2 | |
|
|
2f78c83ef6 | |
|
|
9c3f1d26ad | |
|
|
44c76d80b7 | |
|
|
a12f2273b3 | |
|
|
fb16e224f0 | |
|
|
fb068284db | |
|
|
0281d3722e | |
|
|
cea2421899 | |
|
|
18521339bb | |
|
|
7242f08224 | |
|
|
fbeb3ec2c9 | |
|
|
02273b2d79 | |
|
|
15d5708b5d | |
|
|
7263c9c3ff | |
|
|
6545410d49 | |
|
|
36132bf07c | |
|
|
aca00b8704 | |
|
|
617655a42a | |
|
|
b1b9e4ad93 | |
|
|
8d2ec8e737 | |
|
|
b77cc47791 | |
|
|
1823415a5b | |
|
|
da6ac92391 | |
|
|
4b06c6f83a | |
|
|
be2550885a | |
|
|
fd7a1bbf53 | |
|
|
655eead3b6 | |
|
|
848d111975 | |
|
|
75bdc19f25 | |
|
|
93b92960e7 | |
|
|
ad0a84f2c3 | |
|
|
d7ee63a857 | |
|
|
64c11d548c | |
|
|
a3d3db5437 | |
|
|
c657d6f7a0 | |
|
|
53eab6ac9c | |
|
|
9e6fa67215 | |
|
|
142fb15dc0 | |
|
|
e4b1f7e4d8 | |
|
|
1462700c83 | |
|
|
ac01c7586d | |
|
|
1849bf6654 | |
|
|
1503dd87bb | |
|
|
893cd428a0 | |
|
|
93174db7c8 | |
|
|
9f97a16d6a | |
|
|
84fee9cc38 | |
|
|
bd4e3e507d | |
|
|
627c5a5173 | |
|
|
67e6a8008d | |
|
|
b43bf57ea9 | |
|
|
07e0b22309 | |
|
|
36ab484029 | |
|
|
c78ba865b6 | |
|
|
f1ff835a45 | |
|
|
f15846fd10 | |
|
|
552beabdc0 | |
|
|
652617fe37 | |
|
|
c94b9da813 | |
|
|
39d327fb45 | |
|
|
8dcffa8927 | |
|
|
ab734268a4 | |
|
|
b70ed8aaff | |
|
|
586dde96fb | |
|
|
2c5fe41a21 | |
|
|
7c42e88593 | |
|
|
f3c5c90d7b | |
|
|
30dac204c0 | |
|
|
51c49f7a3d | |
|
|
244c597ac9 | |
|
|
454f79caec | |
|
|
fb9de05b00 | |
|
|
5b98819191 | |
|
|
06c39df3a9 | |
|
|
a7135b4c3c | |
|
|
25c2ab3413 | |
|
|
707328e765 | |
|
|
ed56e14aa2 | |
|
|
a1117092aa | |
|
|
c7d47a6634 | |
|
|
a9577a8f9a | |
|
|
b3e1e620da | |
|
|
13af9a62e8 | |
|
|
5405a7100d | |
|
|
6ef4ff8e9b | |
|
|
5787550cc9 | |
|
|
e8c02fef5e | |
|
|
13fe9c97fe | |
|
|
98a58368a6 | |
|
|
acc2a6169d | |
|
|
17659a0e59 | |
|
|
e4be76fe8d | |
|
|
c0c81f20fc | |
|
|
c387221043 | |
|
|
92fe72f4ed | |
|
|
611fe9f788 | |
|
|
ea88cfd043 | |
|
|
0a6c5fbfcc | |
|
|
b2afe8674e | |
|
|
f0513e20d8 | |
|
|
710ca122ea | |
|
|
8fdf57bedd | |
|
|
629be13816 | |
|
|
11782536f4 | |
|
|
6669a3fc5e | |
|
|
ef0af26147 | |
|
|
a1819e749c | |
|
|
6317ae7b0b | |
|
|
2b8a3945a1 | |
|
|
50545a4570 | |
|
|
f59218aa43 | |
|
|
60832e88ff | |
|
|
d6b9372e1f | |
|
|
080188b419 | |
|
|
e456b4bb69 | |
|
|
5609e32daf | |
|
|
ace80be8e1 | |
|
|
aca39f23d2 | |
|
|
d04330283a | |
|
|
7a52cf76d3 | |
|
|
943d00bbbd | |
|
|
a0180d66a2 | |
|
|
a9f57add62 | |
|
|
5e2392c417 | |
|
|
6fe708505a | |
|
|
f10ceb5f7c | |
|
|
119afcaf42 | |
|
|
a46a2a664f | |
|
|
9fda390c55 | |
|
|
3f60f9ca3e | |
|
|
216e1366ef | |
|
|
711f2670de | |
|
|
00501f359c | |
|
|
b80d6cb85e | |
|
|
1139cea838 | |
|
|
90b7c2b0f0 | |
|
|
51872de821 | |
|
|
d10e00c044 | |
|
|
7994b2a72a | |
|
|
b6eb66d9cb | |
|
|
f286b6c695 | |
|
|
2f3d5f993a | |
|
|
3e414b8530 | |
|
|
ddb1d4cf60 | |
|
|
0c94c4cd5e | |
|
|
1d1597f8e7 | |
|
|
55204dd38c | |
|
|
58427fb8e0 | |
|
|
48b3687e41 | |
|
|
73cd23aee8 | |
|
|
c1e5a2a5f1 | |
|
|
68e8e7b36b | |
|
|
3355ff4563 | |
|
|
be48d30d8f | |
|
|
42435193cf | |
|
|
10526da1ac | |
|
|
dd913d3ecf | |
|
|
8b3593c8fb | |
|
|
14802f507f | |
|
|
3be98234a8 | |
|
|
9b7416b6f8 | |
|
|
c70998fa4f | |
|
|
6ccaa85413 | |
|
|
da0a82a0ec | |
|
|
1e1bc0b2c6 | |
|
|
147f910c88 | |
|
|
1a171d450c | |
|
|
8727ef02f3 | |
|
|
3d32929c0b | |
|
|
fa426625cc | |
|
|
bb49073bf7 | |
|
|
f4d27f51a3 | |
|
|
114a807d79 | |
|
|
4928c54985 | |
|
|
86c671026b | |
|
|
4cbfa8d70d | |
|
|
ef08c3fd7a | |
|
|
96401634b2 | |
|
|
fda7614d48 | |
|
|
25c36167c0 | |
|
|
205d062f4a | |
|
|
0450390b2a | |
|
|
fc16f27640 | |
|
|
dd568b7235 | |
|
|
87fbf5b858 | |
|
|
9b65c1cbff | |
|
|
f765ac4a47 | |
|
|
f6c96d168b | |
|
|
6ea9001a50 | |
|
|
c57e0218fe | |
|
|
3219015a39 | |
|
|
62463e1ca8 | |
|
|
6e5e3a04f3 | |
|
|
95b5e3dc7a | |
|
|
86eb9f0425 | |
|
|
6e92d1855a | |
|
|
c51cd7bc87 | |
|
|
6f3bcd7b46 | |
|
|
d7db8cb07a | |
|
|
eb6fe57839 | |
|
|
818fd5ac0d | |
|
|
2facf19429 | |
|
|
e2cc09b2d6 | |
|
|
3aee36515a | |
|
|
45ac397417 | |
|
|
b46559ba78 | |
|
|
86313c5e89 | |
|
|
cdd9bdfd95 | |
|
|
751a5da119 | |
|
|
6c9ce7a4d9 | |
|
|
461338618e | |
|
|
30cece9bec | |
|
|
68f79db6ed | |
|
|
703183699f | |
|
|
640351d812 | |
|
|
348c040e20 | |
|
|
e3b78309fa | |
|
|
c3f58feef7 | |
|
|
34cd7ba9e3 | |
|
|
6d0acdd1ec | |
|
|
33350a4d46 | |
|
|
d5d267e63a | |
|
|
d4895c363c | |
|
|
762ab8e684 | |
|
|
97b5cd7a5b | |
|
|
f4e4ee13e2 | |
|
|
eeed671436 | |
|
|
8eccdd0b4c | |
|
|
b74cb94191 | |
|
|
bfc86fbcfa | |
|
|
0bedd8bc0b | |
|
|
cec631d0f7 | |
|
|
1acbd76eb8 | |
|
|
9c8ec879d9 | |
|
|
b844953fe0 | |
|
|
ade71313b4 | |
|
|
5f026e88ab | |
|
|
e1a5befdf7 | |
|
|
8272361063 | |
|
|
1a82c8ea94 | |
|
|
108af2a68b | |
|
|
967b76591b | |
|
|
4cd27639e6 | |
|
|
7bb26e0e30 | |
|
|
def94c41f4 | |
|
|
eef1451c5a | |
|
|
e234f88577 | |
|
|
cddce40f35 | |
|
|
3d74b9deb2 | |
|
|
e9268b3f00 | |
|
|
b09bd64083 | |
|
|
23cd677413 | |
|
|
5142a1254e | |
|
|
8006255bbf | |
|
|
aee4e86036 | |
|
|
6839deac97 | |
|
|
84f0a66155 | |
|
|
9d68268910 | |
|
|
289677a971 | |
|
|
cdae78b125 | |
|
|
d218fd7a1a | |
|
|
5bf3c0fcd7 | |
|
|
bc557c4074 | |
|
|
fb9bc268a0 | |
|
|
542f2ccc96 | |
|
|
12000ca059 | |
|
|
91f502c14b | |
|
|
e9c64f65c8 | |
|
|
f4efdd0e0b | |
|
|
be63452834 | |
|
|
a783317820 | |
|
|
d420d1dd40 | |
|
|
d1d76bbea8 | |
|
|
a6e6a14fd1 | |
|
|
227ab1904c | |
|
|
800da3bf21 | |
|
|
7ba05da288 | |
|
|
251c4e3a66 | |
|
|
3e9c566834 | |
|
|
2c099feea0 | |
|
|
660f81edbc | |
|
|
ff23aa7d1d | |
|
|
d1ce14de7a | |
|
|
83008886f0 | |
|
|
226cb6a266 | |
|
|
dd77ddc141 | |
|
|
2ec6e3e920 | |
|
|
7d1ecf718b | |
|
|
e2a4df575c | |
|
|
f5756e184f | |
|
|
385ecdc46a | |
|
|
4fa57d67d6 | |
|
|
361cb56a1d | |
|
|
e6949bdd67 | |
|
|
64e6fd1920 | |
|
|
5533a134c6 | |
|
|
0c1292c55b | |
|
|
02d4a3a3d3 | |
|
|
05273daa92 | |
|
|
2eb8c3a61b | |
|
|
a491f08337 | |
|
|
a3503c0b9f | |
|
|
b3e217c1de | |
|
|
50410475c0 | |
|
|
075869c89c | |
|
|
702b506665 | |
|
|
2a52f25c10 | |
|
|
e21ec4c7b7 | |
|
|
c4ea39bf83 | |
|
|
ab1cbd37b3 | |
|
|
296ee3e825 | |
|
|
76167ab424 | |
|
|
2663400e26 | |
|
|
a828f54663 | |
|
|
f73f788b0a | |
|
|
36bff64145 | |
|
|
9dc8a51f4c | |
|
|
3ddca95af5 | |
|
|
93bf83375e | |
|
|
4e0d5239c6 | |
|
|
658211b9d1 | |
|
|
565ab0b1c0 | |
|
|
bb9124d75b | |
|
|
3b9327f64c | |
|
|
800bd85811 | |
|
|
06ef76814a | |
|
|
2d3e7ba123 | |
|
|
b77fffbad7 | |
|
|
5e8e714e8a | |
|
|
9040faa024 | |
|
|
35024bd669 | |
|
|
c5d8569522 | |
|
|
adb1056b3f | |
|
|
95d3742507 | |
|
|
4f2068a8af | |
|
|
214bd829e9 | |
|
|
d3b7859668 | |
|
|
54b7cae218 | |
|
|
68184ac49f | |
|
|
77faba7e77 | |
|
|
7b84a81a96 | |
|
|
579c4b7387 | |
|
|
2dcf2c4c8e | |
|
|
9cf9b87068 | |
|
|
c40d8ea1ba | |
|
|
cbdd9fef0f | |
|
|
e06f21f63f | |
|
|
df0929db60 | |
|
|
54724fc578 | |
|
|
4fd05ddd59 | |
|
|
436ec1c908 | |
|
|
0f9cd93b8b | |
|
|
e723523ec5 | |
|
|
56cc2ff2e0 | |
|
|
87efafb1c5 | |
|
|
41404e021e | |
|
|
4cdc72e360 | |
|
|
a883187889 | |
|
|
6d1743c524 | |
|
|
5c205753e2 | |
|
|
71fd3f5ee7 | |
|
|
58870237b6 | |
|
|
33ba13b070 | |
|
|
73049c4162 | |
|
|
c6941bc41f | |
|
|
379a3852b6 | |
|
|
f046493960 | |
|
|
fef2f4a132 | |
|
|
35ec16084f | |
|
|
6364b337f6 | |
|
|
aeef1dc215 | |
|
|
84f3ae4e6f | |
|
|
c18cd26ab4 | |
|
|
6534d03ecd | |
|
|
32d4575fb5 | |
|
|
6ebe551caa | |
|
|
abdb6b17f8 | |
|
|
e7ecd0a863 | |
|
|
27e4fb3933 | |
|
|
23911d3dd8 | |
|
|
668b45d4ea | |
|
|
532c80a86b | |
|
|
5a5f86092f | |
|
|
bab960b50e | |
|
|
f6edf8c313 | |
|
|
c5b065ac81 | |
|
|
4ac3f66e7d | |
|
|
0c9356813e | |
|
|
8dee8ac314 | |
|
|
59fa54b812 | |
|
|
2722ebb218 | |
|
|
dad7e9edab | |
|
|
786d71a697 | |
|
|
49f779e0e4 | |
|
|
605fbc4383 | |
|
|
2e0ccaac16 | |
|
|
ccbb6924c8 | |
|
|
0e95f8ed66 | |
|
|
8e74429a83 | |
|
|
2148e8e019 | |
|
|
5d374f902a | |
|
|
99468ca250 | |
|
|
99deab05d8 | |
|
|
5f11b5083f | |
|
|
cdf9c0e562 | |
|
|
2d832c56b6 | |
|
|
1d26b979ac | |
|
|
2a2bf86d12 | |
|
|
d7e598435c | |
|
|
0af0b53638 | |
|
|
ed351f7044 | |
|
|
d0ddc702ac | |
|
|
eb8e5da329 | |
|
|
e7cbbe39a6 | |
|
|
8f41cf7919 | |
|
|
4cd9629a1d | |
|
|
7f68a70b0f | |
|
|
0474937e57 | |
|
|
d8bba7cfc1 | |
|
|
554cdbdea5 | |
|
|
c4290f2d0e | |
|
|
7815a34de4 | |
|
|
7dc420a1a2 | |
|
|
7ab3781372 | |
|
|
3f32996014 | |
|
|
a868c5c413 | |
|
|
15f21a1142 | |
|
|
873addb96a | |
|
|
02644f38ee | |
|
|
ce3ba22c54 | |
|
|
61dc48e638 | |
|
|
4b540dc587 | |
|
|
e9f0244210 | |
|
|
68c3db5213 | |
|
|
94846e92ef | |
|
|
e2f4b47588 | |
|
|
e10d6a3b94 | |
|
|
68577a09f9 | |
|
|
1d6418ca63 | |
|
|
e27845a82f | |
|
|
3009d1eecc | |
|
|
afea879920 | |
|
|
672aba8404 | |
|
|
4294fbf608 | |
|
|
efaa267d78 | |
|
|
7835898a09 | |
|
|
25740c499d | |
|
|
5b79bfb19d | |
|
|
03bce9d643 | |
|
|
732928ac0f | |
|
|
35f130061a | |
|
|
920cdccdf9 | |
|
|
0313c83a65 | |
|
|
20e2729bf7 | |
|
|
242e5bee41 | |
|
|
fb201cc799 | |
|
|
0e4cf7b641 | |
|
|
5d9233203c | |
|
|
15f35d8d94 | |
|
|
2f39b541dd | |
|
|
a2637f4dbb | |
|
|
485780c57c | |
|
|
ead3433f3e | |
|
|
b3cd771b99 | |
|
|
f2500865a6 | |
|
|
c22e38da76 | |
|
|
786576bb76 | |
|
|
832e80cd7f | |
|
|
2e674e13d0 | |
|
|
bc826e8e49 | |
|
|
4affe623a5 | |
|
|
f53a818f2f | |
|
|
b5a83bb0f3 | |
|
|
85e1b532fa | |
|
|
4cd08c3900 | |
|
|
70dc24f7a1 | |
|
|
cd961a2162 | |
|
|
95b341df79 | |
|
|
49935189b6 | |
|
|
939a8696c8 | |
|
|
9f4e71fc68 | |
|
|
b526d8ea2c | |
|
|
3f890cdbfa | |
|
|
7581cd1582 | |
|
|
0839f7f603 | |
|
|
1d87b6c3ac | |
|
|
4b2514d9da | |
|
|
7cc325edd5 | |
|
|
a1cb9d2a8e | |
|
|
05192f6283 | |
|
|
e25f8893b0 | |
|
|
ff2a069b79 | |
|
|
310f43e1bd | |
|
|
4f02f0bad1 | |
|
|
2b2c096a99 | |
|
|
fe306aed26 | |
|
|
4b568f86b1 | |
|
|
107ca3b0b8 | |
|
|
7efb31a367 | |
|
|
9f9e9ecd82 | |
|
|
ec2f544a3e | |
|
|
e964c04523 | |
|
|
fc18523bb6 | |
|
|
8fa068222e | |
|
|
654cc4575b | |
|
|
1ee2d8f365 | |
|
|
f7f410dbbe | |
|
|
7132f4a90f | |
|
|
38734079e8 | |
|
|
44def0979c | |
|
|
cf9e81a216 | |
|
|
4c98839df8 | |
|
|
ad46249c8b | |
|
|
bc029d1df8 | |
|
|
dc6356671f | |
|
|
4a1900bdfa | |
|
|
e65f97b3fe | |
|
|
f3bed0d713 | |
|
|
a560e3682b | |
|
|
0b676098a5 | |
|
|
8b03f3a495 | |
|
|
ba934168f0 | |
|
|
df779ac04c | |
|
|
9429033e2c | |
|
|
8489ff03c2 | |
|
|
fe1c99c727 | |
|
|
573a300a4a | |
|
|
c6b2a30651 | |
|
|
b4cc844675 | |
|
|
6c713a11d8 | |
|
|
82ff18e388 | |
|
|
63b6e89435 | |
|
|
acaa3414d2 | |
|
|
2b3f883909 | |
|
|
f4fd1184cd | |
|
|
10c7c9a0b1 | |
|
|
87938456b6 | |
|
|
3d7942b5f4 | |
|
|
198f678b68 | |
|
|
958aeb2d53 | |
|
|
36ea8115cb | |
|
|
01e03dedbf | |
|
|
66b735e864 | |
|
|
b8e30c9557 | |
|
|
37796ecc9d | |
|
|
6901baab8e | |
|
|
5b8bad17ef | |
|
|
7b0bbc91c8 | |
|
|
9f131a80ab | |
|
|
1e7be6c61c | |
|
|
39080dff59 | |
|
|
7cf455083d | |
|
|
2f9b4f27b8 | |
|
|
eb17309b50 | |
|
|
07ff643a19 | |
|
|
d64ca5a8c0 | |
|
|
4dde008c6d | |
|
|
d08ae88a93 | |
|
|
7425c37094 | |
|
|
d428a70b69 | |
|
|
c50c8d01df | |
|
|
6b53cb414c | |
|
|
0d6b018ec4 | |
|
|
b468b51aa7 | |
|
|
5629cd999f | |
|
|
257912ea92 | |
|
|
94e5a5de0b | |
|
|
d7164531ef | |
|
|
4dba7c0a16 | |
|
|
e089b41395 | |
|
|
7aecae559b | |
|
|
9a3d65d8d0 | |
|
|
a3f945f5df | |
|
|
6a329506a8 | |
|
|
e732ed2891 | |
|
|
d9681bb64d | |
|
|
57738fbfc2 | |
|
|
fd7fc754f4 | |
|
|
9f4884f761 | |
|
|
e6cc671808 | |
|
|
4386a009a4 | |
|
|
ac40f0227e | |
|
|
71f38a38e0 | |
|
|
eb9c85f786 | |
|
|
b607ef0aa0 | |
|
|
8248c8dc96 | |
|
|
c7db82a8a5 | |
|
|
297870a24c | |
|
|
e0e7bc387e | |
|
|
cbf8576897 | |
|
|
714511c3cf | |
|
|
40efb391ea | |
|
|
f9bd7bbcb3 | |
|
|
516bcafb2c | |
|
|
5376d7198d | |
|
|
6aa25fa852 | |
|
|
3a75549ffe | |
|
|
7e63e882a1 | |
|
|
3332c87293 | |
|
|
511884f323 | |
|
|
3ada095e43 | |
|
|
c8540b170e | |
|
|
d536fd01da | |
|
|
97ce6d3691 | |
|
|
48cacf0409 | |
|
|
7edd0cc1b0 | |
|
|
9f501aa839 | |
|
|
4a5c21a3ba | |
|
|
787bfd363f | |
|
|
107f722e7a | |
|
|
56cd2a9407 | |
|
|
a3a4664bb0 | |
|
|
b40e3d4b8b | |
|
|
dcf07fdd5e | |
|
|
8a77e6d33c | |
|
|
1c571ee3c3 | |
|
|
68aafb3732 | |
|
|
7b676a6aff | |
|
|
c9905a6dea | |
|
|
c9eacb8f4a | |
|
|
2ddda380f2 | |
|
|
8e9daf5b22 | |
|
|
21f4f30859 | |
|
|
aef62454c2 | |
|
|
1d9634ac41 | |
|
|
90d56d29ab | |
|
|
c76ea1c676 | |
|
|
cd5e7095cd | |
|
|
2279630143 | |
|
|
5c2e147784 | |
|
|
4219489ddc | |
|
|
0f6ac2e58f | |
|
|
dc7e7714f7 | |
|
|
e42675616b | |
|
|
9a674b6686 | |
|
|
27d278ca8c | |
|
|
6d9c7ed7bf | |
|
|
085679a95a | |
|
|
3d6ce26f9d | |
|
|
b54413978b | |
|
|
e086719235 | |
|
|
44031506f3 | |
|
|
6dbeffa91f | |
|
|
0b30c76b35 | |
|
|
afc384f0d9 | |
|
|
cff8f39bc3 | |
|
|
5604771d23 | |
|
|
394a8579e3 | |
|
|
5b2392acf9 | |
|
|
0bb314f8e5 | |
|
|
a580ed186d | |
|
|
224e9a2522 | |
|
|
548241e768 | |
|
|
64068007d5 | |
|
|
0a767480cd | |
|
|
a819ea6bfa | |
|
|
9953014b88 | |
|
|
5d1d11869c | |
|
|
148155e6fe | |
|
|
4924fbe71d | |
|
|
95dc16160e | |
|
|
7b6132953c | |
|
|
b8e767e5b9 | |
|
|
88b2dea627 | |
|
|
21af6c5c17 | |
|
|
5d533f0cbf | |
|
|
4010273d67 | |
|
|
101db2dfa2 | |
|
|
0776e7cd4c | |
|
|
58e1aec262 | |
|
|
0e9e5f29cf | |
|
|
dea88dd42b | |
|
|
444b2fab2b | |
|
|
4d9e783c57 | |
|
|
556354219a | |
|
|
c0860359e5 | |
|
|
8c6aeb006b | |
|
|
382b75c87b | |
|
|
234f82b944 | |
|
|
8f38b176ab | |
|
|
2959f66e0c | |
|
|
ec3e74706c | |
|
|
efa28d8a47 | |
|
|
c6a51279d6 | |
|
|
865831e41e | |
|
|
1c1a8633ae | |
|
|
437e0c331c | |
|
|
c5b0d35885 | |
|
|
2517261db9 | |
|
|
244f04a199 | |
|
|
9a3cc2cc93 | |
|
|
398c47618b | |
|
|
8edd5e4ca6 | |
|
|
f7e03792f6 | |
|
|
5c8a913698 | |
|
|
f62af9077a | |
|
|
44023d4ff6 | |
|
|
0dab71edfe | |
|
|
efdef36cda | |
|
|
ca2904dfc4 | |
|
|
ce508fb48a | |
|
|
3371c4c954 | |
|
|
9bff4c77e3 | |
|
|
e826648778 | |
|
|
3198684f27 | |
|
|
e880083600 | |
|
|
eed28ba0a9 | |
|
|
50aeacd9ea | |
|
|
d21764ba51 | |
|
|
88d71da1a9 | |
|
|
7a0fee15d4 | |
|
|
fd4215ca9c | |
|
|
83034cff02 | |
|
|
eeae338cd4 | |
|
|
0fe2fa9db1 | |
|
|
bf809e729b | |
|
|
86f561c484 | |
|
|
7c3db548bc | |
|
|
81458549af | |
|
|
743ae6dbf1 | |
|
|
b5605d93da | |
|
|
711e051b1c | |
|
|
dcb32f26b0 | |
|
|
775fbf8903 | |
|
|
e1c40b23fb | |
|
|
3f3779c25e | |
|
|
28ecc31128 | |
|
|
53a0fa5c6a | |
|
|
2a968ab3cf | |
|
|
71beae8e24 | |
|
|
fb73ee2878 | |
|
|
1291f9287c | |
|
|
9eed3eb710 | |
|
|
d13422f7ac | |
|
|
025fe04192 | |
|
|
63db466504 | |
|
|
2c30d40623 | |
|
|
e6c11a0e04 | |
|
|
f0cae99c8d | |
|
|
57f7c48cd9 | |
|
|
7c45b3e254 | |
|
|
c0f2fbbd88 | |
|
|
c333a9fd9d | |
|
|
d5e72ce901 | |
|
|
c48eec78ac | |
|
|
561d9cb855 | |
|
|
25f6217433 | |
|
|
39bd9c3351 | |
|
|
b09a7c8398 | |
|
|
c52e77f37d | |
|
|
34353b712c | |
|
|
35581ac8d2 | |
|
|
6dd321ddab | |
|
|
5b394473f4 | |
|
|
821336d40d | |
|
|
d7e8feafc8 | |
|
|
15776e76f5 | |
|
|
9b337496b8 | |
|
|
02df4355e2 | |
|
|
29c49d7f07 | |
|
|
8a318ea741 | |
|
|
640a9a741c | |
|
|
d1e399b1c4 | |
|
|
8788b47663 | |
|
|
270c322daf | |
|
|
d4579e4221 | |
|
|
4f2cf6c0ff | |
|
|
cc4dd5ffdc | |
|
|
4bbe29e18e | |
|
|
bc36c00712 | |
|
|
189f0e03a0 | |
|
|
783ce5594e | |
|
|
a3bfcdf2d8 | |
|
|
3b5f0b638f | |
|
|
f0bb349c8c | |
|
|
5fdefffd26 | |
|
|
f14d9ee66c | |
|
|
a9d85b780b | |
|
|
1116bb2b73 | |
|
|
463cbd29f9 | |
|
|
ef5b86cc4c | |
|
|
07f65e43c7 | |
|
|
ce45502f60 | |
|
|
0a776ff358 | |
|
|
6d54a4c9ea | |
|
|
31bd9c26b7 | |
|
|
4e3dbd4bc8 | |
|
|
addff4769b | |
|
|
bfc0c3fc39 | |
|
|
7bbe88d7ae | |
|
|
25cd23c1fb | |
|
|
4b52fe6394 | |
|
|
9a9133bbfd | |
|
|
ff4646d816 | |
|
|
7d6281d289 | |
|
|
0a57a2cef1 | |
|
|
8d1f0e7098 | |
|
|
9f9c1e933f | |
|
|
4f8d6fe875 | |
|
|
7c701c4a0f | |
|
|
96252270d7 | |
|
|
dc17988466 | |
|
|
d22369a050 | |
|
|
03039ab743 | |
|
|
bc8587f688 | |
|
|
759665978b | |
|
|
5f4d78640b | |
|
|
6fe49721db | |
|
|
f84c06a7a7 | |
|
|
d9088816a7 | |
|
|
ea9ed488e8 | |
|
|
9c10ddedc1 | |
|
|
998e6a8596 | |
|
|
bee5d37ab0 | |
|
|
0b8bc258f3 | |
|
|
ce07ca3c00 | |
|
|
c4fdec35ac | |
|
|
5f703b5ae8 | |
|
|
23be8a0eee | |
|
|
2f51b9632d | |
|
|
43654f7516 | |
|
|
c228ddb498 | |
|
|
67e838dc03 | |
|
|
4ca4ea3b3c | |
|
|
bb926b1c58 | |
|
|
b242a85801 | |
|
|
4f2dd0710e | |
|
|
b402602b69 | |
|
|
f9c6ef70db | |
|
|
5fa335a83e | |
|
|
eebf80e028 | |
|
|
a6ad975ced | |
|
|
901fae9814 | |
|
|
eb49594161 | |
|
|
5203d0fa50 | |
|
|
8c89b9cf86 | |
|
|
b3afd923c9 | |
|
|
ee77a46168 | |
|
|
8179946cd8 | |
|
|
6a5cf839a7 | |
|
|
3172f772ba | |
|
|
aa3cd95a36 | |
|
|
70d2c96c80 | |
|
|
d668814e03 | |
|
|
84ce175d95 | |
|
|
64658d5d5d | |
|
|
afe4074d37 | |
|
|
6422dac2a4 | |
|
|
60ef6a6a95 | |
|
|
b104cd94f2 | |
|
|
ec1669d9ca | |
|
|
331261bc80 | |
|
|
23f7b89cc5 | |
|
|
4996dd5562 | |
|
|
16d0c1eda8 | |
|
|
c439596cbf | |
|
|
ce7f02409c | |
|
|
a4473eee33 | |
|
|
1b6d63bf74 | |
|
|
753c170839 | |
|
|
8ec54b4e7d | |
|
|
745e540d40 | |
|
|
cb5ed105ab | |
|
|
d60473f96f | |
|
|
184adffdcb | |
|
|
b66b7c66f0 | |
|
|
900ac4b76e | |
|
|
8ab36f32a0 | |
|
|
e934cc945b | |
|
|
d29d4b596d | |
|
|
0c3ce4d3eb | |
|
|
73e3bf4159 | |
|
|
298fd11169 | |
|
|
9bd84f898a | |
|
|
2dd96f5a74 | |
|
|
e1602613fe | |
|
|
5e1d2507da | |
|
|
13f4d07577 | |
|
|
41c763c019 | |
|
|
7c3a2dff4c | |
|
|
0a28445abe | |
|
|
9dca73f4c4 | |
|
|
479b0ba3ed | |
|
|
f5dddc99eb | |
|
|
f1d74cfd0e | |
|
|
994d9b70cd | |
|
|
458e1018b0 | |
|
|
96df465a7d | |
|
|
eb1cac4a77 | |
|
|
0198426c46 | |
|
|
01ebb2550c | |
|
|
85987af65e | |
|
|
79fef2691d | |
|
|
45de532b84 | |
|
|
fc0bc3e5c8 | |
|
|
8a421cfced | |
|
|
2433658e01 | |
|
|
bdf9bd0075 | |
|
|
1470bb2e73 | |
|
|
01f92d6132 | |
|
|
e290076708 | |
|
|
63553e23b1 | |
|
|
d63e092245 | |
|
|
0823874ebc | |
|
|
774332558b | |
|
|
b62f2ffc10 | |
|
|
40d8bcfe0f | |
|
|
1d0c4fe503 | |
|
|
7ec60bed6c | |
|
|
76ad3d9c43 | |
|
|
3033b02634 | |
|
|
9cb705dba8 | |
|
|
10d112bd69 | |
|
|
3fd325972f | |
|
|
8c18555305 | |
|
|
2305b8dfae | |
|
|
d3c9a42525 | |
|
|
8a2aa49910 | |
|
|
71111ce072 | |
|
|
55601481d7 | |
|
|
ec853fb45d | |
|
|
eac43cfb31 | |
|
|
5ca0a6b6dc | |
|
|
d57756189f | |
|
|
eadff1a051 | |
|
|
656f1c2ebd | |
|
|
fa30763ae2 | |
|
|
7fe246bd93 | |
|
|
c6465d3138 | |
|
|
5d260c7716 | |
|
|
ec4d8f9b94 | |
|
|
3c0cd6f6dc | |
|
|
74d287daa9 | |
|
|
874cf485a8 | |
|
|
a292188ffc | |
|
|
e9bd677780 | |
|
|
cc84e604b1 | |
|
|
16333eac06 | |
|
|
d43a8c9e18 | |
|
|
74ebb565e6 | |
|
|
93675100da | |
|
|
b479eb789a | |
|
|
0108a12b18 | |
|
|
641a171270 | |
|
|
0d96ea566b | |
|
|
967f9a9f5b | |
|
|
45d00e10e7 | |
|
|
f088a2d995 | |
|
|
687dccb522 | |
|
|
b30e3480d4 | |
|
|
c6c56a1239 | |
|
|
2263863456 | |
|
|
090cba09f1 | |
|
|
efa2cbc538 | |
|
|
0937336453 | |
|
|
5fb9e19e5a | |
|
|
11729b2e26 | |
|
|
d6d179025d | |
|
|
be1bd6a40a | |
|
|
21688d3815 | |
|
|
c3064ac01f | |
|
|
74487b5455 | |
|
|
6fc50cd315 | |
|
|
12e5d99339 | |
|
|
70f6093fb5 | |
|
|
1e3136cb0b | |
|
|
7e0f08388c | |
|
|
7b3b856476 | |
|
|
1d8d90e5c6 | |
|
|
d8ea7981fe | |
|
|
876e3bfa05 | |
|
|
1f12df2f79 | |
|
|
54a559a309 | |
|
|
86135dcf10 | |
|
|
3b3067d067 | |
|
|
72045df25a | |
|
|
bb1ab5d192 | |
|
|
2e19b929ad | |
|
|
8eb2f21583 | |
|
|
40e9958690 | |
|
|
7d8abc0449 | |
|
|
7ceecd15af | |
|
|
3eac709478 | |
|
|
84994a30e8 | |
|
|
597b9b9a51 | |
|
|
652aa1e9b0 | |
|
|
b711680a3c | |
|
|
5b503edfa8 | |
|
|
f16f75c083 | |
|
|
821be53b19 | |
|
|
2b7519519a | |
|
|
16a1c459fc | |
|
|
dd9bfff056 | |
|
|
ff58c84ac0 | |
|
|
49669b37e5 | |
|
|
cd893c2fa3 | |
|
|
c5a4b0b10c | |
|
|
e2d99aef40 | |
|
|
f6848df87a | |
|
|
de9491aa29 | |
|
|
f9c171c513 | |
|
|
66a8196411 | |
|
|
755da0d1bf | |
|
|
f1f282bb34 | |
|
|
0217393cb8 | |
|
|
ab07908f09 | |
|
|
aba6283e3f | |
|
|
8932f61298 | |
|
|
3f60bba109 | |
|
|
b2264f967c | |
|
|
ff838536bf | |
|
|
5745540f0e | |
|
|
71075f84b5 | |
|
|
809ded3746 | |
|
|
8b28107147 | |
|
|
6603ff81fe | |
|
|
52c7391cf5 | |
|
|
40d923212a | |
|
|
d3a3237e7a | |
|
|
d2fd1f1967 | |
|
|
f0707f35d8 | |
|
|
d473ace18d | |
|
|
4a4700ea23 | |
|
|
2e916678fa | |
|
|
2a8081a253 | |
|
|
184d687f0f | |
|
|
eb38f6ae9c | |
|
|
0a02a6c7ab | |
|
|
b41da3261c | |
|
|
3b57d4acda | |
|
|
e53bdd15ef | |
|
|
ddc8963b9b | |
|
|
ec0acb3890 | |
|
|
d948aa3d3d | |
|
|
54e9f45823 | |
|
|
829161d195 | |
|
|
fa08a26079 | |
|
|
92e7cef2bc | |
|
|
29a2a18d69 | |
|
|
d7a845ad9f | |
|
|
e5aa9d46f5 | |
|
|
963e57d7e7 | |
|
|
7241d1b91f | |
|
|
7155f76345 | |
|
|
317b4ed1e7 | |
|
|
2ff0d77e62 | |
|
|
bd64762d4a | |
|
|
cb224be93e | |
|
|
abed34ce9a | |
|
|
5093d336c0 | |
|
|
7097775343 | |
|
|
ac53b3c440 | |
|
|
8e2c66e2a4 | |
|
|
a0dde51109 | |
|
|
6d51aced2c | |
|
|
062efac47f | |
|
|
71aaef7acb | |
|
|
26d27ac881 | |
|
|
c5499d2e18 | |
|
|
a8c1b4b5e5 | |
|
|
8e0ef82de7 | |
|
|
3bda194bf2 | |
|
|
a7123216f2 | |
|
|
9168844fab | |
|
|
02f67c2372 | |
|
|
9349867476 | |
|
|
8f676c0a6d | |
|
|
9e1a7c53e1 | |
|
|
337cc448d0 | |
|
|
ed9da3962a | |
|
|
3afcd3d9fb | |
|
|
18e2280623 | |
|
|
716cfcb2cf | |
|
|
7e38f82d0c | |
|
|
c42853f261 | |
|
|
4addf8dccf | |
|
|
0c3462a646 | |
|
|
7686158a01 | |
|
|
04fea9a526 | |
|
|
1995adf245 | |
|
|
010635a407 | |
|
|
d7613713cf | |
|
|
59bd654107 | |
|
|
0b5b140625 | |
|
|
a709046a6f | |
|
|
720917fcab | |
|
|
8b2a195eff | |
|
|
d4949983fb | |
|
|
2d5224c938 | |
|
|
39ddf59275 | |
|
|
3613f9eef4 | |
|
|
95c685051d | |
|
|
eff3b45dc9 | |
|
|
d709515d6d | |
|
|
8799568cc6 | |
|
|
03635ff82e | |
|
|
254a7c5bd5 | |
|
|
4c05b25fd8 | |
|
|
cb2377b8d7 | |
|
|
3d242c1c8e | |
|
|
2c546bac28 | |
|
|
c8f66a9f18 | |
|
|
28e0a6af7d | |
|
|
b797d864ff | |
|
|
3ecdf73bc5 | |
|
|
31fcceef20 | |
|
|
f7fc0debe5 | |
|
|
5a8efa51af | |
|
|
736d777bef | |
|
|
36aec28708 | |
|
|
4cb967fea6 | |
|
|
14d079b34f | |
|
|
593983d6ee | |
|
|
980dc8125b | |
|
|
2e84c4272f | |
|
|
a7de47e7ea | |
|
|
bed23e256b | |
|
|
ea97ffcade | |
|
|
f4c73aaa34 | |
|
|
9599d34ba9 | |
|
|
dae3f2d4a8 | |
|
|
2d57c5e9ee | |
|
|
c896b2182c | |
|
|
104c5671f6 | |
|
|
a2c3737f7a | |
|
|
6a3ee2f132 | |
|
|
230888daa7 | |
|
|
c6930a4e66 | |
|
|
60090873b3 | |
|
|
f040e8eb35 | |
|
|
4cc5f1344f | |
|
|
3db7feb36b | |
|
|
e667ee7106 | |
|
|
2050a22656 | |
|
|
8bc8df4eb8 | |
|
|
c3f24cdece | |
|
|
5cd5ad6c49 | |
|
|
ce599fab22 | |
|
|
909024b635 | |
|
|
2b104b8455 | |
|
|
55f52ed1b5 | |
|
|
d149f0baaa | |
|
|
0d4b985d5a | |
|
|
7cad74a41f | |
|
|
2311729338 | |
|
|
85c561c8b5 | |
|
|
4dbb55f6e1 | |
|
|
97902d2a49 | |
|
|
7ccd8fbc6a | |
|
|
788745ef37 | |
|
|
ce65e6106d | |
|
|
f65caf45cd | |
|
|
dac3e927aa | |
|
|
4813da827e | |
|
|
9775b28d9d | |
|
|
dadd49b98f | |
|
|
2754be3250 | |
|
|
5b58681603 | |
|
|
55d8d5432e | |
|
|
28d460fecd | |
|
|
26649b78f3 | |
|
|
75865e2283 | |
|
|
87bec6760a | |
|
|
289e396406 | |
|
|
f88bedc0ab | |
|
|
a53940cff9 | |
|
|
216e19e25d | |
|
|
e8123932ba | |
|
|
cabaada5b8 | |
|
|
c9e5e2ee72 | |
|
|
faf201dfc6 | |
|
|
3672bbd997 | |
|
|
534d174234 | |
|
|
c99936cef0 | |
|
|
0dc4d53876 | |
|
|
39e3aa14cb | |
|
|
3d48c0f7cb | |
|
|
f73229eeeb | |
|
|
cf909cded6 | |
|
|
fbb42dd83c | |
|
|
b6eaaed85e | |
|
|
7a10b2652c | |
|
|
2dea3cfaa0 | |
|
|
51dea84bc5 | |
|
|
df64841c1e | |
|
|
95c98cbda3 | |
|
|
25cf0b77a1 | |
|
|
b6f93e686d | |
|
|
d1b2e6c010 | |
|
|
32024a6d70 | |
|
|
9d5ac1716d | |
|
|
6e41fdf039 | |
|
|
8046c2a2e0 | |
|
|
2849f7e116 | |
|
|
71eb308bba | |
|
|
f456ab89e8 | |
|
|
7828b5e073 | |
|
|
8efdb93a1c | |
|
|
b4c5be1f17 | |
|
|
57c4e8317d | |
|
|
a219878288 | |
|
|
734e78c2da | |
|
|
4e1e5b0d51 | |
|
|
27e33e27d1 | |
|
|
c23d372bcd | |
|
|
c9c416d6fd | |
|
|
fdc476a9e0 | |
|
|
ae616ae611 | |
|
|
c52937c22d | |
|
|
e697acb2c9 | |
|
|
b32b05a76c | |
|
|
ae23cfdc2b | |
|
|
f19db38973 | |
|
|
ed908b2330 | |
|
|
7d801c0a2b | |
|
|
dfa642798e | |
|
|
d83264181c | |
|
|
f8be19c49f | |
|
|
d01ade4e4f | |
|
|
a1ddf4678d | |
|
|
172ecf34b3 | |
|
|
722a413916 | |
|
|
43cdacb194 | |
|
|
46aa81ce6f | |
|
|
771dc8cf56 | |
|
|
c5c6d9239c | |
|
|
ae23a4408e | |
|
|
1c00ee28e8 | |
|
|
62d36abb65 | |
|
|
2ee4dd0b58 | |
|
|
12087cbdd7 | |
|
|
dfac694e4d | |
|
|
de97c40517 | |
|
|
7cefc39b74 | |
|
|
7a588b47f6 | |
|
|
579d4224d5 | |
|
|
6a221d3e7e | |
|
|
aad1a7b447 | |
|
|
21caac8c63 | |
|
|
93e5331d6c | |
|
|
bd72f7892b | |
|
|
213f482a6f | |
|
|
4b52d6aed8 | |
|
|
ede881f8ff |
|
|
@ -0,0 +1,749 @@
|
|||
---
|
||||
description: 관리자 페이지 표준 스타일 가이드 - shadcn/ui 기반 일관된 디자인 시스템
|
||||
globs: **/app/(main)/admin/**/*.tsx,**/components/admin/**/*.tsx
|
||||
---
|
||||
|
||||
# 관리자 페이지 표준 스타일 가이드
|
||||
|
||||
이 가이드는 관리자 페이지의 일관된 UI/UX를 위한 표준 스타일 규칙입니다.
|
||||
모든 관리자 페이지는 이 가이드를 따라야 합니다.
|
||||
|
||||
## 1. 페이지 레이아웃 구조
|
||||
|
||||
### 기본 페이지 템플릿
|
||||
|
||||
```tsx
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
|
||||
<p className="text-sm text-muted-foreground">페이지 설명</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<MainComponent />
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**필수 적용 사항:**
|
||||
|
||||
- 최상위: `flex min-h-screen flex-col bg-background`
|
||||
- 컨텐츠 영역: `space-y-6 p-6` (24px 좌우 여백, 24px 간격)
|
||||
- 헤더 구분선: `border-b pb-4` (테두리 박스 사용 금지)
|
||||
- Scroll to Top: 모든 관리자 페이지에 포함
|
||||
|
||||
## 2. Color System (색상 시스템)
|
||||
|
||||
### CSS Variables 사용 (하드코딩 금지)
|
||||
|
||||
```tsx
|
||||
// ❌ 잘못된 예시
|
||||
<div className="bg-gray-50 text-gray-900 border-gray-200">
|
||||
|
||||
// ✅ 올바른 예시
|
||||
<div className="bg-background text-foreground border-border">
|
||||
<div className="bg-card text-card-foreground">
|
||||
<div className="bg-muted text-muted-foreground">
|
||||
```
|
||||
|
||||
**표준 색상 토큰:**
|
||||
|
||||
- `bg-background` / `text-foreground`: 기본 배경/텍스트
|
||||
- `bg-card` / `text-card-foreground`: 카드 배경/텍스트
|
||||
- `bg-muted` / `text-muted-foreground`: 보조 배경/텍스트
|
||||
- `bg-primary` / `text-primary`: 메인 액션
|
||||
- `bg-destructive` / `text-destructive`: 삭제/에러
|
||||
- `border-border`: 테두리
|
||||
- `ring-ring`: 포커스 링
|
||||
|
||||
## 3. Typography (타이포그래피)
|
||||
|
||||
### 표준 텍스트 크기와 가중치
|
||||
|
||||
```tsx
|
||||
// 페이지 제목
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
|
||||
// 섹션 제목
|
||||
<h2 className="text-xl font-semibold">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<h4 className="text-sm font-semibold">
|
||||
|
||||
// 본문 텍스트
|
||||
<p className="text-sm">
|
||||
|
||||
// 보조 텍스트
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
// 라벨
|
||||
<label className="text-sm font-medium">
|
||||
```
|
||||
|
||||
## 4. Spacing System (간격)
|
||||
|
||||
### 일관된 간격 (4px 기준)
|
||||
|
||||
```tsx
|
||||
// 페이지 레벨 간격
|
||||
<div className="space-y-6"> // 24px
|
||||
|
||||
// 섹션 레벨 간격
|
||||
<div className="space-y-4"> // 16px
|
||||
|
||||
// 필드 레벨 간격
|
||||
<div className="space-y-2"> // 8px
|
||||
|
||||
// 패딩
|
||||
<div className="p-6"> // 24px (카드)
|
||||
<div className="p-4"> // 16px (내부 섹션)
|
||||
|
||||
// 갭
|
||||
<div className="gap-4"> // 16px (flex/grid)
|
||||
<div className="gap-2"> // 8px (버튼 그룹)
|
||||
```
|
||||
|
||||
## 5. 검색 툴바 (Toolbar)
|
||||
|
||||
### 패턴 A: 통합 검색 영역 (권장)
|
||||
|
||||
```tsx
|
||||
<div className="space-y-4">
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 검색 영역 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* 통합 검색 */}
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="통합 검색..." className="h-10 pl-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 토글 */}
|
||||
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||
고급 검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{count.toLocaleString()}
|
||||
</span>{" "}
|
||||
건
|
||||
</div>
|
||||
<Button className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 옵션 */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
|
||||
<p className="text-xs text-muted-foreground">설명</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input placeholder="필드 검색" className="h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 패턴 B: 제목 + 검색 + 버튼 한 줄 (공간 효율적)
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 상단 헤더: 제목 + 검색 + 버튼 */
|
||||
}
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 왼쪽: 제목 */}
|
||||
<h2 className="text-xl font-semibold">페이지 제목</h2>
|
||||
|
||||
{/* 오른쪽: 검색 + 버튼 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* 필터 선택 */}
|
||||
<div className="w-full sm:w-[160px]">
|
||||
<Select>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="필터" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="w-full sm:w-[240px]">
|
||||
<Input placeholder="검색..." className="h-10 text-sm" />
|
||||
</div>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" className="h-10 text-sm font-medium">
|
||||
초기화
|
||||
</Button>
|
||||
|
||||
{/* 주요 액션 버튼 */}
|
||||
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
|
||||
{/* 조건부 버튼 (선택 시) */}
|
||||
{selectedCount > 0 && (
|
||||
<Button variant="destructive" className="h-10 gap-2 text-sm font-medium">
|
||||
삭제 ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
**필수 적용 사항:**
|
||||
|
||||
- ❌ 검색 영역에 박스/테두리 사용 금지
|
||||
- ✅ 검색창 권장 너비: `w-full sm:w-[240px]` ~ `sm:w-[400px]`
|
||||
- ✅ 필터/Select 권장 너비: `w-full sm:w-[160px]` ~ `sm:w-[200px]`
|
||||
- ✅ 고급 검색 필드: placeholder만 사용 (라벨 제거)
|
||||
- ✅ 검색 아이콘: `Search` (lucide-react)
|
||||
- ✅ Input/Select 높이: `h-10` (40px)
|
||||
- ✅ 상단 헤더에 `relative` 추가 (드롭다운 표시용)
|
||||
|
||||
## 6. Button (버튼)
|
||||
|
||||
### 표준 버튼 variants와 크기
|
||||
|
||||
```tsx
|
||||
// Primary 액션
|
||||
<Button variant="default" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
|
||||
// Secondary 액션
|
||||
<Button variant="outline" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||
취소
|
||||
</Button>
|
||||
|
||||
// Ghost 버튼 (아이콘 전용)
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
// Destructive
|
||||
<Button variant="destructive" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||
삭제
|
||||
</Button>
|
||||
```
|
||||
|
||||
**표준 크기:**
|
||||
|
||||
- `h-10`: 기본 버튼 (40px)
|
||||
- `h-9`: 작은 버튼 (36px)
|
||||
- `h-8`: 아이콘 버튼 (32px)
|
||||
|
||||
**아이콘 크기:**
|
||||
|
||||
- `h-4 w-4`: 버튼 내 아이콘 (16px)
|
||||
|
||||
## 7. Input (입력 필드)
|
||||
|
||||
### 표준 Input 스타일
|
||||
|
||||
```tsx
|
||||
// 기본
|
||||
<Input placeholder="입력..." className="h-10 text-sm" />
|
||||
|
||||
// 검색 (아이콘 포함)
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="검색..." className="h-10 pl-10 text-sm" />
|
||||
</div>
|
||||
|
||||
// 로딩/액티브
|
||||
<Input className="h-10 text-sm border-primary ring-2 ring-primary/20" />
|
||||
|
||||
// 비활성화
|
||||
<Input disabled className="h-10 text-sm cursor-not-allowed bg-muted text-muted-foreground" />
|
||||
```
|
||||
|
||||
**필수 적용 사항:**
|
||||
|
||||
- 높이: `h-10` (40px)
|
||||
- 텍스트: `text-sm`
|
||||
- 포커스: 자동 적용 (`ring-2 ring-ring`)
|
||||
|
||||
## 8. Table & Card (테이블과 카드)
|
||||
|
||||
### 반응형 테이블/카드 구조
|
||||
|
||||
```tsx
|
||||
// 실제 데이터 렌더링
|
||||
return (
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">컬럼</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 text-sm">데이터</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold">{item.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.id}</p>
|
||||
</div>
|
||||
<Switch checked={item.active} />
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">필드</span>
|
||||
<span className="font-medium">{item.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
액션
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
**테이블 표준:**
|
||||
|
||||
- 헤더: `h-12` (48px), `bg-muted/50`, `font-semibold`
|
||||
- 데이터 행: `h-16` (64px), `hover:bg-muted/50`
|
||||
- 텍스트: `text-sm`
|
||||
|
||||
**카드 표준:**
|
||||
|
||||
- 컨테이너: `rounded-lg border bg-card p-4 shadow-sm`
|
||||
- 헤더 제목: `text-base font-semibold`
|
||||
- 부제목: `text-sm text-muted-foreground`
|
||||
- 정보 라벨: `text-sm text-muted-foreground`
|
||||
- 정보 값: `text-sm font-medium`
|
||||
- 버튼: `h-9 flex-1 gap-2 text-sm`
|
||||
|
||||
## 9. Loading States (로딩 상태)
|
||||
|
||||
### Skeleton UI 패턴
|
||||
|
||||
```tsx
|
||||
// 테이블 스켈레톤 (데스크톱)
|
||||
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<TableRow key={index} className="border-b">
|
||||
<TableCell className="h-16">
|
||||
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
// 카드 스켈레톤 (모바일/태블릿)
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
<div className="h-6 w-11 animate-pulse rounded-full bg-muted"></div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 10. Empty States (빈 상태)
|
||||
|
||||
### 표준 Empty State
|
||||
|
||||
```tsx
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11. Error States (에러 상태)
|
||||
|
||||
### 표준 에러 메시지
|
||||
|
||||
```tsx
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
오류가 발생했습니다
|
||||
</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-destructive transition-colors hover:text-destructive/80"
|
||||
aria-label="에러 메시지 닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-destructive/80">{errorMessage}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 12. Responsive Design (반응형)
|
||||
|
||||
### Breakpoints
|
||||
|
||||
- `sm`: 640px (모바일 가로/태블릿)
|
||||
- `md`: 768px (태블릿)
|
||||
- `lg`: 1024px (노트북)
|
||||
- `xl`: 1280px (데스크톱)
|
||||
|
||||
### 모바일 우선 패턴
|
||||
|
||||
```tsx
|
||||
// 레이아웃
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
|
||||
// 그리드
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
// 검색창
|
||||
<div className="w-full sm:w-[400px]">
|
||||
|
||||
// 테이블/카드 전환
|
||||
<div className="hidden lg:block"> {/* 데스크톱 테이블 */}
|
||||
<div className="lg:hidden"> {/* 모바일 카드 */}
|
||||
|
||||
// 간격
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="gap-2 sm:gap-4">
|
||||
```
|
||||
|
||||
## 13. 좌우 레이아웃 (Side-by-Side Layout)
|
||||
|
||||
### 사이드바 + 메인 영역 구조
|
||||
|
||||
```tsx
|
||||
<div className="flex h-full gap-6">
|
||||
{/* 좌측 사이드바 (20-30%) */}
|
||||
<div className="w-[20%] border-r pr-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">사이드바 제목</h3>
|
||||
|
||||
{/* 사이드바 컨텐츠 */}
|
||||
<div className="space-y-3">
|
||||
<div className="cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md">
|
||||
<h4 className="text-sm font-semibold">항목</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 메인 영역 (70-80%) */}
|
||||
<div className="w-[80%] pl-0">
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
<h2 className="text-xl font-semibold">메인 제목</h2>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">{/* 컨텐츠 */}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**필수 적용 사항:**
|
||||
|
||||
- ✅ 좌우 구분: `border-r` 사용 (세로 구분선)
|
||||
- ✅ 간격: `gap-6` (24px)
|
||||
- ✅ 사이드바 패딩: `pr-6` (오른쪽 24px)
|
||||
- ✅ 메인 영역 패딩: `pl-0` (gap으로 간격 확보)
|
||||
- ✅ 비율: 20:80 또는 30:70
|
||||
- ❌ 과도한 구분선 사용 금지 (세로 구분선 1개만)
|
||||
- ❌ 사이드바와 메인 영역 각각에 추가 border 금지
|
||||
|
||||
## 14. Custom Dropdown (커스텀 드롭다운)
|
||||
|
||||
### 커스텀 Select/Dropdown 구조
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 드롭다운 컨테이너 */
|
||||
}
|
||||
<div className="w-full sm:w-[160px]">
|
||||
<div className="company-dropdown relative">
|
||||
{/* 트리거 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className={!value ? "text-muted-foreground" : ""}>
|
||||
{value || "선택하세요"}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 드롭다운 메뉴 */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
|
||||
{/* 검색 (선택사항) */}
|
||||
<div className="border-b p-2">
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 목록 */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setValue(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
**필수 적용 사항:**
|
||||
|
||||
- ✅ z-index: `z-[100]` (다른 요소 위에 표시)
|
||||
- ✅ 그림자: `shadow-lg` (명확한 레이어 구분)
|
||||
- ✅ 최소 너비: `min-w-[200px]` (내용이 잘리지 않도록)
|
||||
- ✅ 최대 높이: `max-h-48` (스크롤 가능)
|
||||
- ✅ 애니메이션: 화살표 아이콘 회전 (`rotate-180`)
|
||||
- ✅ 부모 요소: `relative` 클래스 필요
|
||||
- ⚠️ 부모에 `overflow-hidden` 사용 시 드롭다운 잘림 주의
|
||||
|
||||
**드롭다운이 잘릴 때 해결방법:**
|
||||
|
||||
```tsx
|
||||
// 부모 요소의 overflow 제거
|
||||
<div className="w-[80%] pl-0"> // overflow-hidden 제거
|
||||
|
||||
// 또는 상단 헤더에 relative 추가
|
||||
<div className="relative flex ..."> // 드롭다운 포지셔닝 기준점
|
||||
```
|
||||
|
||||
## 15. Scroll to Top Button
|
||||
|
||||
### 모바일/태블릿 전용 버튼
|
||||
|
||||
```tsx
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
|
||||
// 페이지에 추가
|
||||
<ScrollToTop />;
|
||||
```
|
||||
|
||||
**특징:**
|
||||
|
||||
- 데스크톱에서 숨김 (`lg:hidden`)
|
||||
- 스크롤 200px 이상 시 나타남
|
||||
- 부드러운 페이드 인/아웃 애니메이션
|
||||
- 오른쪽 하단 고정 위치
|
||||
- 원형 디자인 (`rounded-full`)
|
||||
|
||||
## 14. Accessibility (접근성)
|
||||
|
||||
### 필수 적용 사항
|
||||
|
||||
```tsx
|
||||
// Label과 Input 연결
|
||||
<label htmlFor="field-id" className="text-sm font-medium">
|
||||
라벨
|
||||
</label>
|
||||
<Input id="field-id" />
|
||||
|
||||
// 버튼에 aria-label
|
||||
<Button aria-label="설명">
|
||||
<Icon />
|
||||
</Button>
|
||||
|
||||
// Switch에 aria-label
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="상태 토글"
|
||||
/>
|
||||
|
||||
// 포커스 표시 (자동 적용)
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
|
||||
```
|
||||
|
||||
## 15. Class 순서 (일관성)
|
||||
|
||||
### 표준 클래스 작성 순서
|
||||
|
||||
1. Layout: `flex`, `grid`, `block`
|
||||
2. Position: `fixed`, `absolute`, `relative`
|
||||
3. Sizing: `w-full`, `h-10`
|
||||
4. Spacing: `p-4`, `m-2`, `gap-4`
|
||||
5. Typography: `text-sm`, `font-medium`
|
||||
6. Colors: `bg-primary`, `text-white`
|
||||
7. Border: `border`, `rounded-md`
|
||||
8. Effects: `shadow-sm`, `opacity-50`
|
||||
9. States: `hover:`, `focus:`, `disabled:`
|
||||
10. Responsive: `sm:`, `md:`, `lg:`
|
||||
|
||||
## 16. 금지 사항
|
||||
|
||||
### ❌ 절대 사용하지 말 것
|
||||
|
||||
1. 하드코딩된 색상 (`bg-gray-50`, `text-blue-500` 등)
|
||||
2. 인라인 스타일로 색상 지정 (`style={{ color: '#3b82f6' }}`)
|
||||
3. 포커스 스타일 제거 (`outline-none`만 단독 사용)
|
||||
4. 중첩된 박스 (Card 안에 Card, Border 안에 Border)
|
||||
5. 검색 영역에 불필요한 박스/테두리
|
||||
6. 검색 필드에 라벨 (placeholder만 사용)
|
||||
7. 반응형 무시 (데스크톱 전용 스타일)
|
||||
8. **이모지 사용** (사용자가 명시적으로 요청하지 않는 한 절대 사용 금지)
|
||||
9. 과도한 구분선 사용 (최소한으로 유지)
|
||||
10. 드롭다운 부모에 `overflow-hidden` (잘림 발생)
|
||||
|
||||
## 17. 체크리스트
|
||||
|
||||
새로운 관리자 페이지 작성 시 다음을 확인하세요:
|
||||
|
||||
### 페이지 레벨
|
||||
|
||||
- [ ] `bg-background` 사용 (하드코딩 금지)
|
||||
- [ ] `space-y-6 p-6` 구조
|
||||
- [ ] 페이지 헤더에 `border-b pb-4`
|
||||
- [ ] `ScrollToTop` 컴포넌트 포함
|
||||
|
||||
### 검색 툴바
|
||||
|
||||
- [ ] 박스/테두리 없음
|
||||
- [ ] 검색창 최대 너비 `sm:w-[400px]`
|
||||
- [ ] 고급 검색 필드에 라벨 없음 (placeholder만)
|
||||
- [ ] 반응형 레이아웃 적용
|
||||
|
||||
### 테이블/카드
|
||||
|
||||
- [ ] 데스크톱: 테이블 (`hidden lg:block`)
|
||||
- [ ] 모바일: 카드 (`lg:hidden`)
|
||||
- [ ] 표준 높이와 간격 적용
|
||||
- [ ] 로딩/Empty 상태 구현
|
||||
|
||||
### 버튼
|
||||
|
||||
- [ ] 표준 variants 사용
|
||||
- [ ] 표준 높이: `h-10`, `h-9`, `h-8`
|
||||
- [ ] 아이콘 크기: `h-4 w-4`
|
||||
- [ ] `gap-2`로 아이콘과 텍스트 간격
|
||||
|
||||
### 반응형
|
||||
|
||||
- [ ] 모바일 우선 디자인
|
||||
- [ ] Breakpoints 적용 (`sm:`, `lg:`)
|
||||
- [ ] 테이블/카드 전환
|
||||
- [ ] Scroll to Top 버튼
|
||||
|
||||
### 접근성
|
||||
|
||||
- [ ] Label `htmlFor` / Input `id` 연결
|
||||
- [ ] 버튼 `aria-label`
|
||||
- [ ] Switch `aria-label`
|
||||
- [ ] 포커스 표시 유지
|
||||
|
||||
## 참고 파일
|
||||
|
||||
완성된 예시:
|
||||
|
||||
### 기본 패턴
|
||||
|
||||
- [사용자 관리 페이지](<mdc:frontend/app/(main)/admin/userMng/page.tsx>) - 기본 페이지 구조
|
||||
- [검색 툴바](mdc:frontend/components/admin/UserToolbar.tsx) - 패턴 A (통합 검색)
|
||||
- [테이블/카드](mdc:frontend/components/admin/UserTable.tsx) - 반응형 테이블/카드
|
||||
- [Scroll to Top](mdc:frontend/components/common/ScrollToTop.tsx) - 스크롤 버튼
|
||||
|
||||
### 고급 패턴
|
||||
|
||||
- [메뉴 관리 페이지](<mdc:frontend/app/(main)/admin/menu/page.tsx>) - 좌우 레이아웃 + 패턴 B (제목+검색+버튼)
|
||||
- [메뉴 관리 컴포넌트](mdc:frontend/components/admin/MenuManagement.tsx) - 커스텀 드롭다운 + 좌우 레이아웃
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
# AI-개발자 협업 작업 수칙
|
||||
|
||||
## 핵심 원칙: "추측 금지, 확인 필수"
|
||||
|
||||
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 관련 작업
|
||||
|
||||
### 필수 확인 사항
|
||||
|
||||
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
|
||||
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
|
||||
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
|
||||
|
||||
### 확인 방법
|
||||
|
||||
```sql
|
||||
-- 테이블 구조 확인
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- 실제 데이터 확인
|
||||
SELECT * FROM 테이블명 LIMIT 5;
|
||||
```
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
|
||||
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
|
||||
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
|
||||
|
||||
---
|
||||
|
||||
## 2. 코드 수정 작업
|
||||
|
||||
### 작업 전
|
||||
|
||||
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
|
||||
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
|
||||
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
|
||||
|
||||
### 작업 중
|
||||
|
||||
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
|
||||
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
|
||||
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
|
||||
|
||||
### 작업 후
|
||||
|
||||
1. **로그 제거**: 디버깅 로그는 반드시 제거
|
||||
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
|
||||
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
|
||||
|
||||
---
|
||||
|
||||
## 3. 확인 및 검증
|
||||
|
||||
### 확인 도구 사용
|
||||
|
||||
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
|
||||
- **MCP Browser**: 실제 화면에서 동작 확인
|
||||
- **codebase_search**: 관련 코드 패턴 검색
|
||||
- **grep**: 특정 문자열 사용처 찾기
|
||||
|
||||
### 검증 프로세스
|
||||
|
||||
1. **변경 전 상태 확인** → 문제 파악
|
||||
2. **변경 적용**
|
||||
3. **변경 후 상태 확인** → 해결 검증
|
||||
4. **부작용 확인** → 다른 기능에 영향 없는지
|
||||
|
||||
### 사용자 피드백 대응
|
||||
|
||||
- 사용자가 "확인 안하지?"라고 하면:
|
||||
1. 즉시 사과
|
||||
2. MCP/브라우저로 실제 확인
|
||||
3. 정확한 정보를 바탕으로 재작업
|
||||
|
||||
---
|
||||
|
||||
## 4. 커뮤니케이션
|
||||
|
||||
### 작업 시작 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
|
||||
|
||||
❌ 나쁜 예:
|
||||
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
|
||||
```
|
||||
|
||||
### 작업 완료 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"완료! 두 가지를 수정했습니다:
|
||||
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
|
||||
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
|
||||
|
||||
테스트해보세요!"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"수정했습니다!"
|
||||
```
|
||||
|
||||
### 불확실할 때
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
|
||||
MCP로 확인해도 될까요?"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"created_at일 것 같으니 일단 이렇게 하겠습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 금지 사항
|
||||
|
||||
### 절대 금지
|
||||
|
||||
1. ❌ **확인 없이 "완료했습니다" 말하기**
|
||||
- 반드시 실제로 확인하고 보고
|
||||
2. ❌ **이전에 실패한 방법 반복하기**
|
||||
- 같은 실수를 두 번 하지 않기
|
||||
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
|
||||
- 모든 console.log 제거 확인
|
||||
4. ❌ **추측으로 답변하기**
|
||||
|
||||
- "아마도", "보통", "일반적으로" 금지
|
||||
- 확실하지 않으면 먼저 확인
|
||||
|
||||
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
|
||||
- 한 번에 하나씩 해결
|
||||
|
||||
---
|
||||
|
||||
## 6. 프로젝트 특별 규칙
|
||||
|
||||
### 백엔드 관련
|
||||
|
||||
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
|
||||
- 🔥 Node.js 프로세스를 건드리지 않음
|
||||
|
||||
### 데이터베이스 관련
|
||||
|
||||
- 🔥 **멀티테넌시 규칙 준수**
|
||||
- 모든 쿼리에 `company_code` 필터링 필수
|
||||
- `company_code = "*"`는 최고 관리자 전용
|
||||
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
|
||||
|
||||
### API 관련
|
||||
|
||||
- 🔥 **API 클라이언트 사용 필수**
|
||||
- `fetch()` 직접 사용 금지
|
||||
- `lib/api/` 의 클라이언트 함수 사용
|
||||
- 환경별 URL 자동 처리
|
||||
|
||||
### UI 관련
|
||||
|
||||
- 🔥 **shadcn/ui 스타일 가이드 준수**
|
||||
- CSS 변수 사용 (하드코딩 금지)
|
||||
- 중첩 박스 금지 (명시 요청 전까지)
|
||||
- 이모지 사용 금지 (명시 요청 전까지)
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
### 에러 발생 시 프로세스
|
||||
|
||||
1. **에러 로그 전체 읽기**
|
||||
|
||||
- 스택 트레이스 확인
|
||||
- 에러 메시지 정확히 파악
|
||||
|
||||
2. **근본 원인 파악**
|
||||
|
||||
- 증상이 아닌 원인 찾기
|
||||
- 왜 이 에러가 발생했는지 이해
|
||||
|
||||
3. **해결책 적용**
|
||||
|
||||
- 임시방편이 아닌 근본적 해결
|
||||
- 같은 에러가 재발하지 않도록
|
||||
|
||||
4. **검증**
|
||||
- 실제로 에러가 해결되었는지 확인
|
||||
- 다른 부작용은 없는지 확인
|
||||
|
||||
### 에러 로깅
|
||||
|
||||
```typescript
|
||||
// ✅ 좋은 로그 (디버깅 시)
|
||||
console.log("🔍 [컴포넌트명] 작업명:", {
|
||||
관련변수1,
|
||||
관련변수2,
|
||||
예상결과,
|
||||
});
|
||||
|
||||
// ❌ 나쁜 로그
|
||||
console.log("here");
|
||||
console.log(data); // 무슨 데이터인지 알 수 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 완료 체크리스트
|
||||
|
||||
모든 작업 완료 전에 다음을 확인:
|
||||
|
||||
- [ ] 실제 데이터베이스/파일을 확인했는가?
|
||||
- [ ] 변경사항이 의도대로 작동하는가?
|
||||
- [ ] 디버깅 로그를 모두 제거했는가?
|
||||
- [ ] 다른 기능에 부작용이 없는가?
|
||||
- [ ] 멀티테넌시 규칙을 준수했는가?
|
||||
- [ ] 사용자에게 명확히 설명했는가?
|
||||
|
||||
---
|
||||
|
||||
## 9. 모범 사례
|
||||
|
||||
### 데이터베이스 확인 예시
|
||||
|
||||
```typescript
|
||||
// 1. MCP로 테이블 구조 확인
|
||||
mcp_postgres_query: SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'item_info';
|
||||
|
||||
// 2. 실제 컬럼명 확인 후 코드 작성
|
||||
const hiddenColumns = new Set([
|
||||
'id',
|
||||
'created_date', // ✅ 실제 확인한 컬럼명
|
||||
'updated_date', // ✅ 실제 확인한 컬럼명
|
||||
'writer', // ✅ 실제 확인한 컬럼명
|
||||
'company_code'
|
||||
]);
|
||||
```
|
||||
|
||||
### 브라우저 테스트 제안 예시
|
||||
|
||||
```
|
||||
"수정이 완료되었습니다!
|
||||
|
||||
다음을 테스트해주세요:
|
||||
1. 화면관리 > 테이블 탭 열기
|
||||
2. item_info 테이블 확인
|
||||
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
|
||||
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
|
||||
|
||||
브라우저 테스트를 원하시면 말씀해주세요!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 요약: 핵심 3원칙
|
||||
|
||||
1. **확인 우선** 🔍
|
||||
|
||||
- 추측하지 말고, 항상 확인하고 작업
|
||||
|
||||
2. **한 번에 하나** 🎯
|
||||
|
||||
- 여러 문제를 동시에 해결하려 하지 말기
|
||||
|
||||
3. **철저한 마무리** ✨
|
||||
- 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
---
|
||||
|
||||
## 11. 화면관리 시스템 위젯 개발 가이드
|
||||
|
||||
### 위젯 크기 설정의 핵심 원칙
|
||||
|
||||
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
|
||||
|
||||
#### ✅ 올바른 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 위젯 컴포넌트 내부
|
||||
export function YourWidget({ component }: YourWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-between gap-2"
|
||||
style={{
|
||||
padding: component.style?.padding || "0.75rem",
|
||||
backgroundColor: component.style?.backgroundColor,
|
||||
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
|
||||
}}
|
||||
>
|
||||
{/* 위젯 내용 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 이렇게 하면 안 됩니다!
|
||||
<div
|
||||
style={{
|
||||
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
|
||||
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
|
||||
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### 이유
|
||||
|
||||
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
|
||||
|
||||
```tsx
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: getWidth(), // size.width 사용
|
||||
height: getHeight(), // size.height 사용
|
||||
};
|
||||
```
|
||||
|
||||
2. 위젯 내부에서 크기를 다시 설정하면:
|
||||
- 중복 설정으로 인한 충돌
|
||||
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
|
||||
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
|
||||
|
||||
### 위젯이 관리해야 할 스타일
|
||||
|
||||
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
|
||||
|
||||
- ✅ `padding`: 내부 여백
|
||||
- ✅ `backgroundColor`: 배경색
|
||||
- ✅ `border`, `borderRadius`: 테두리
|
||||
- ✅ `gap`: 자식 요소 간격
|
||||
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
|
||||
|
||||
### 위젯 등록 시 defaultSize
|
||||
|
||||
```tsx
|
||||
ComponentRegistry.registerComponent({
|
||||
id: "your-widget",
|
||||
name: "위젯 이름",
|
||||
category: "utility",
|
||||
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
|
||||
component: YourWidget,
|
||||
defaultProps: {
|
||||
style: {
|
||||
padding: "0.75rem",
|
||||
// width, height는 defaultSize로 제어되므로 여기 불필요
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 전체 높이를 차지하고 내부 요소를 정렬
|
||||
<div className="flex h-full w-full items-center justify-between gap-2">
|
||||
{/* 왼쪽 컨텐츠 */}
|
||||
<div className="flex items-center gap-3">{/* ... */}</div>
|
||||
|
||||
{/* 오른쪽 버튼들 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 체크리스트
|
||||
|
||||
위젯 개발 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
|
||||
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
|
||||
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
|
||||
- [ ] `defaultSize`에 적절한 기본 크기 설정
|
||||
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
|
||||
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙
|
||||
---
|
||||
|
||||
# API 클라이언트 사용 규칙
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.**
|
||||
|
||||
## 이유
|
||||
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
|
||||
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
|
||||
4. **유지보수성**: API 변경 시 한 곳에서만 수정
|
||||
|
||||
## API 클라이언트 위치
|
||||
|
||||
```
|
||||
frontend/lib/api/
|
||||
├── client.ts # Axios 기반 공통 클라이언트
|
||||
├── flow.ts # 플로우 관리 API
|
||||
├── dashboard.ts # 대시보드 API
|
||||
├── mail.ts # 메일 API
|
||||
├── externalCall.ts # 외부 호출 API
|
||||
├── company.ts # 회사 관리 API
|
||||
└── file.ts # 파일 업로드/다운로드 API
|
||||
```
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ❌ 잘못된 방법 (절대 사용 금지)
|
||||
|
||||
```typescript
|
||||
// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음
|
||||
const response = await fetch("/api/flow/definitions/29/steps");
|
||||
const data = await response.json();
|
||||
|
||||
// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청
|
||||
const response = await fetch(`/api/flow/${flowId}/steps`);
|
||||
```
|
||||
|
||||
### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// 1. API 클라이언트 함수 import
|
||||
import { getFlowSteps } from "@/lib/api/flow";
|
||||
|
||||
// 2. 함수 호출
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
if (stepsResponse.success && stepsResponse.data) {
|
||||
setSteps(stepsResponse.data);
|
||||
}
|
||||
```
|
||||
|
||||
## 주요 API 클라이언트 함수
|
||||
|
||||
### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts))
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getFlowDefinitions, // 플로우 목록
|
||||
getFlowById, // 플로우 상세
|
||||
createFlowDefinition, // 플로우 생성
|
||||
updateFlowDefinition, // 플로우 수정
|
||||
deleteFlowDefinition, // 플로우 삭제
|
||||
getFlowSteps, // 스텝 목록 ⭐
|
||||
createFlowStep, // 스텝 생성
|
||||
updateFlowStep, // 스텝 수정
|
||||
deleteFlowStep, // 스텝 삭제
|
||||
getFlowConnections, // 연결 목록 ⭐
|
||||
createFlowConnection, // 연결 생성
|
||||
deleteFlowConnection, // 연결 삭제
|
||||
getStepDataCount, // 스텝 데이터 카운트
|
||||
getStepDataList, // 스텝 데이터 목록
|
||||
getAllStepCounts, // 모든 스텝 카운트
|
||||
moveData, // 데이터 이동
|
||||
moveBatchData, // 배치 데이터 이동
|
||||
getAuditLogs, // 오딧 로그
|
||||
} from "@/lib/api/flow";
|
||||
```
|
||||
|
||||
### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts))
|
||||
|
||||
```typescript
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
// GET 요청
|
||||
const response = await apiClient.get("/api/endpoint");
|
||||
|
||||
// POST 요청
|
||||
const response = await apiClient.post("/api/endpoint", { data });
|
||||
|
||||
// PUT 요청
|
||||
const response = await apiClient.put("/api/endpoint", { data });
|
||||
|
||||
// DELETE 요청
|
||||
const response = await apiClient.delete("/api/endpoint");
|
||||
```
|
||||
|
||||
## 새로운 API 함수 추가 가이드
|
||||
|
||||
기존 API 클라이언트에 함수가 없는 경우:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/yourModule.ts
|
||||
|
||||
// 1. API URL 동적 설정 (필수)
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
// 2. API 함수 작성
|
||||
export async function getYourData(id: number): Promise<ApiResponse<YourType>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/your-endpoint/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 환경별 URL 매핑
|
||||
|
||||
API 클라이언트는 자동으로 환경을 감지합니다:
|
||||
|
||||
| 현재 호스트 | 백엔드 API URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `v1.vexplor.com` | `https://api.vexplor.com/api` |
|
||||
| `localhost:9771` | `http://localhost:8080/api` |
|
||||
| `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
## 체크리스트
|
||||
|
||||
코드 작성 시 다음을 확인하세요:
|
||||
|
||||
- [ ] `fetch('/api/...')` 직접 사용하지 않음
|
||||
- [ ] 적절한 API 클라이언트 함수를 import 함
|
||||
- [ ] API 응답의 `success` 필드를 체크함
|
||||
- [ ] 에러 처리를 구현함
|
||||
- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가
|
||||
|
||||
## 예외 상황
|
||||
|
||||
다음 경우에만 `fetch`를 직접 사용할 수 있습니다:
|
||||
|
||||
1. **외부 서비스 호출**: 다른 도메인의 API 호출 시
|
||||
2. **특수한 헤더가 필요한 경우**: FormData, Blob 등
|
||||
|
||||
이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요.
|
||||
|
||||
## 실제 적용 예시
|
||||
|
||||
### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
const connectionsResponse = await getFlowConnections(flowId);
|
||||
```
|
||||
|
||||
### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
```
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts)
|
||||
- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts)
|
||||
- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts)
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
# inputType 사용 가이드
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ✅ inputType 사용 (권장)
|
||||
|
||||
```typescript
|
||||
// 카테고리 타입 체크
|
||||
if (columnMeta.inputType === "category") {
|
||||
// 카테고리 처리 로직
|
||||
}
|
||||
|
||||
// 코드 타입 체크
|
||||
if (meta.inputType === "code") {
|
||||
// 코드 처리 로직
|
||||
}
|
||||
|
||||
// 필터링
|
||||
const categoryColumns = Object.entries(columnMeta)
|
||||
.filter(([_, meta]) => meta.inputType === "category")
|
||||
.map(([columnName, _]) => columnName);
|
||||
```
|
||||
|
||||
### ❌ webType 사용 (금지)
|
||||
|
||||
```typescript
|
||||
// ❌ 절대 사용 금지!
|
||||
if (columnMeta.webType === "category") { ... }
|
||||
|
||||
// ❌ 이것도 금지!
|
||||
const categoryColumns = columns.filter(col => col.webType === "category");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API에서 inputType 가져오기
|
||||
|
||||
### Backend API
|
||||
|
||||
```typescript
|
||||
// 컬럼 입력 타입 정보 가져오기
|
||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
|
||||
|
||||
// inputType 맵 생성
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
```
|
||||
|
||||
### columnMeta 구조
|
||||
|
||||
```typescript
|
||||
interface ColumnMeta {
|
||||
webType?: string; // 레거시, 사용 금지
|
||||
codeCategory?: string;
|
||||
inputType?: string; // ✅ 반드시 이것 사용!
|
||||
}
|
||||
|
||||
const columnMeta: Record<string, ColumnMeta> = {
|
||||
material: {
|
||||
webType: "category", // 무시
|
||||
codeCategory: "",
|
||||
inputType: "category", // ✅ 이것만 사용
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 캐시 사용 시 주의사항
|
||||
|
||||
### ❌ 잘못된 캐시 처리 (inputType 누락)
|
||||
|
||||
```typescript
|
||||
const cached = tableColumnCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const meta: Record<string, ColumnMeta> = {};
|
||||
|
||||
cached.columns.forEach((col: any) => {
|
||||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
// ❌ inputType 누락!
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 올바른 캐시 처리 (inputType 포함)
|
||||
|
||||
```typescript
|
||||
const cached = tableColumnCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const meta: Record<string, ColumnMeta> = {};
|
||||
|
||||
// 캐시된 inputTypes 맵 생성
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
if (cached.inputTypes) {
|
||||
cached.inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
}
|
||||
|
||||
cached.columns.forEach((col: any) => {
|
||||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 inputType 종류
|
||||
|
||||
| inputType | 설명 | 사용 예시 |
|
||||
| ---------- | ---------------- | ------------------ |
|
||||
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
|
||||
| `number` | 숫자 입력 | 금액, 수량 등 |
|
||||
| `date` | 날짜 입력 | 생성일, 수정일 등 |
|
||||
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
|
||||
| `category` | 카테고리 선택 | 분류, 상태 등 |
|
||||
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
|
||||
| `boolean` | 예/아니오 | 활성화 여부 등 |
|
||||
| `email` | 이메일 입력 | 이메일 주소 |
|
||||
| `url` | URL 입력 | 웹사이트 주소 |
|
||||
| `image` | 이미지 업로드 | 프로필 사진 등 |
|
||||
| `file` | 파일 업로드 | 첨부파일 등 |
|
||||
|
||||
---
|
||||
|
||||
## 실제 적용 사례
|
||||
|
||||
### 1. TableListComponent - 카테고리 매핑 로드
|
||||
|
||||
```typescript
|
||||
// ✅ inputType으로 카테고리 컬럼 필터링
|
||||
const categoryColumns = Object.entries(columnMeta)
|
||||
.filter(([_, meta]) => meta.inputType === "category")
|
||||
.map(([columnName, _]) => columnName);
|
||||
|
||||
// 각 카테고리 컬럼의 값 목록 조회
|
||||
for (const columnName of categoryColumns) {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values`
|
||||
);
|
||||
// 매핑 처리...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. InteractiveDataTable - 셀 값 렌더링
|
||||
|
||||
```typescript
|
||||
// ✅ inputType으로 렌더링 분기
|
||||
const inputType = columnMeta[column.columnName]?.inputType;
|
||||
|
||||
switch (inputType) {
|
||||
case "category":
|
||||
// 카테고리 배지 렌더링
|
||||
return <Badge>{categoryLabel}</Badge>;
|
||||
|
||||
case "code":
|
||||
// 코드명 표시
|
||||
return codeName;
|
||||
|
||||
case "date":
|
||||
// 날짜 포맷팅
|
||||
return formatDate(value);
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 검색 필터 생성
|
||||
|
||||
```typescript
|
||||
// ✅ inputType에 따라 다른 검색 UI 제공
|
||||
const renderSearchInput = (column: ColumnConfig) => {
|
||||
const inputType = columnMeta[column.columnName]?.inputType;
|
||||
|
||||
switch (inputType) {
|
||||
case "category":
|
||||
return <CategorySelect column={column} />;
|
||||
|
||||
case "code":
|
||||
return <CodeSelect column={column} />;
|
||||
|
||||
case "date":
|
||||
return <DateRangePicker column={column} />;
|
||||
|
||||
case "number":
|
||||
return <NumberRangeInput column={column} />;
|
||||
|
||||
default:
|
||||
return <TextInput column={column} />;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 체크리스트
|
||||
|
||||
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
|
||||
|
||||
- [ ] `webType` 참조를 모두 `inputType`으로 변경
|
||||
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
|
||||
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
|
||||
- [ ] 타입 정의에서 `inputType` 필드 포함
|
||||
- [ ] 조건문에서 `inputType` 체크로 변경
|
||||
- [ ] 테스트 실행하여 정상 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 팁
|
||||
|
||||
### inputType이 undefined인 경우
|
||||
|
||||
```typescript
|
||||
// 디버깅 로그 추가
|
||||
console.log("columnMeta:", columnMeta);
|
||||
console.log("inputType:", columnMeta[columnName]?.inputType);
|
||||
|
||||
// 체크 포인트:
|
||||
// 1. getColumnInputTypes() 호출 확인
|
||||
// 2. inputTypeMap 생성 확인
|
||||
// 3. meta 객체에 inputType 할당 확인
|
||||
// 4. 캐시 사용 시 cached.inputTypes 확인
|
||||
```
|
||||
|
||||
### webType만 있고 inputType이 없는 경우
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 데이터 구조
|
||||
{
|
||||
material: {
|
||||
webType: "category",
|
||||
codeCategory: "",
|
||||
// inputType 누락!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 올바른 데이터 구조
|
||||
{
|
||||
material: {
|
||||
webType: "category", // 레거시, 무시됨
|
||||
codeCategory: "",
|
||||
inputType: "category" // ✅ 필수!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
|
||||
- **타입 정의**: `/frontend/types/table.ts`
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
1. **항상 `inputType` 사용**, `webType` 사용 금지
|
||||
2. **API에서 `getColumnInputTypes()` 호출** 필수
|
||||
3. **캐시 사용 시 `inputTypes` 포함** 확인
|
||||
4. **디버깅 시 `inputType` 값 확인**
|
||||
5. **기존 코드 마이그레이션** 시 체크리스트 활용
|
||||
|
|
@ -0,0 +1,844 @@
|
|||
---
|
||||
priority: critical
|
||||
applies_to: all
|
||||
check_frequency: always
|
||||
enforcement: mandatory
|
||||
---
|
||||
|
||||
# 멀티테넌시(Multi-Tenancy) 필수 구현 가이드
|
||||
|
||||
**🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.**
|
||||
|
||||
**⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.**
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.**
|
||||
|
||||
이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.
|
||||
다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 스키마 요구사항
|
||||
|
||||
### 1.1 company_code 컬럼 필수
|
||||
|
||||
**모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE example_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL, -- ✅ 필수!
|
||||
name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 외래키 제약조건 (필수)
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 성능을 위한 인덱스 (필수)
|
||||
CREATE INDEX idx_example_company_code ON example_table(company_code);
|
||||
|
||||
-- 복합 유니크 제약조건 (중복 방지)
|
||||
CREATE UNIQUE INDEX idx_example_unique
|
||||
ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우
|
||||
```
|
||||
|
||||
### 1.2 예외 테이블 (company_code 불필요)
|
||||
|
||||
**⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.**
|
||||
|
||||
이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다.
|
||||
|
||||
**모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:**
|
||||
|
||||
- ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속)
|
||||
- ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능)
|
||||
- ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정)
|
||||
- ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그)
|
||||
- ✅ 모든 비즈니스 테이블 → `company_code` 필수
|
||||
|
||||
**새로운 테이블 생성 시 체크리스트:**
|
||||
|
||||
- [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외)
|
||||
- [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음)
|
||||
- [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요!
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 API 구현 필수 사항
|
||||
|
||||
### 2.1 모든 데이터 조회 시 필터링
|
||||
|
||||
**절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 데이터 조회 가능
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
logger.info("최고 관리자 전체 데이터 조회");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 데이터만 조회
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
logger.info("회사별 데이터 조회", { companyCode });
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법 - 절대 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 치명적 보안 취약점: company_code 필터링 없음
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출!
|
||||
const result = await pool.query(query);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 데이터 생성 (INSERT)
|
||||
|
||||
**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function createData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 서버에서 확정
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, name, description]);
|
||||
|
||||
logger.info("데이터 생성", {
|
||||
companyCode,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 클라이언트 입력 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능
|
||||
async function createData(req: Request, res: Response) {
|
||||
const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능!
|
||||
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
|
||||
await pool.query(query, [companyCode, name]);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 데이터 수정 (UPDATE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function updateData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND company_code = $4
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 수정", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨
|
||||
const query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2
|
||||
WHERE id = $3
|
||||
`;
|
||||
```
|
||||
|
||||
### 2.4 데이터 삭제 (DELETE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function deleteData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 삭제 가능
|
||||
query = `DELETE FROM example_table WHERE id = $1 RETURNING id`;
|
||||
params = [id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 삭제 가능
|
||||
query = `
|
||||
DELETE FROM example_table
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. company_code = "\*" 의 의미
|
||||
|
||||
### 3.1 최고 관리자 전용 데이터
|
||||
|
||||
**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
|
||||
|
||||
- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
|
||||
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
|
||||
|
||||
### 3.2 데이터 격리 원칙
|
||||
|
||||
**회사별 데이터 접근 규칙:**
|
||||
|
||||
| 사용자 유형 | company_code | 접근 가능한 데이터 |
|
||||
| ----------- | ------------ | ---------------------------------------------- |
|
||||
| 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 |
|
||||
| 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 |
|
||||
| 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 |
|
||||
|
||||
**핵심**:
|
||||
|
||||
- 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음**
|
||||
- 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음**
|
||||
- 최고 관리자만 모든 데이터에 접근 가능
|
||||
|
||||
---
|
||||
|
||||
## 4. 복잡한 쿼리에서의 멀티테넌시
|
||||
|
||||
### 4.1 JOIN 쿼리
|
||||
|
||||
**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name,
|
||||
c.name as user_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b
|
||||
ON a.category_id = b.id
|
||||
AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수
|
||||
LEFT JOIN user_info c
|
||||
ON a.user_id = c.user_id
|
||||
AND a.company_code = c.company_code
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음!
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.2 서브쿼리
|
||||
|
||||
**모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1 -- ✅
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 서브쿼리에서 company_code 누락
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true -- company_code 없음!
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.3 집계 함수 (COUNT, SUM 등)
|
||||
|
||||
**집계 함수도 company_code로 필터링해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 모든 회사의 총합 반환
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
```
|
||||
|
||||
### 4.4 EXISTS 서브쿼리
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM related_table b
|
||||
WHERE b.example_id = a.id
|
||||
AND b.company_code = a.company_code -- ✅ 필수
|
||||
)
|
||||
AND a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 자동 필터 시스템 (autoFilter)
|
||||
|
||||
### 5.1 백엔드 구현 (이미 완료)
|
||||
|
||||
백엔드에는 `autoFilter` 기능이 구현되어 있습니다:
|
||||
|
||||
```typescript
|
||||
// tableManagementController.ts
|
||||
let enhancedSearch = { ...search };
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 프론트엔드 사용 (필수)
|
||||
|
||||
**모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/screen.ts
|
||||
const requestBody = {
|
||||
...params,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
requestBody
|
||||
);
|
||||
```
|
||||
|
||||
#### Entity 조인 API
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/entityJoin.ts
|
||||
const autoFilter = {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
};
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/table-management/tables/${tableName}/data-with-joins`,
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
autoFilter: JSON.stringify(autoFilter),
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 서비스 계층 패턴
|
||||
|
||||
### 6.1 표준 서비스 함수 패턴
|
||||
|
||||
**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.**
|
||||
|
||||
```typescript
|
||||
class ExampleService {
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table`;
|
||||
params = [];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE company_code = $1`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
return await pool.query(query, params);
|
||||
}
|
||||
|
||||
async findById(companyCode: string, id: number) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async create(companyCode: string, data: any) {
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(query, [
|
||||
companyCode,
|
||||
data.name,
|
||||
data.description,
|
||||
]);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 컨트롤러에서 사용
|
||||
const exampleService = new ExampleService();
|
||||
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await exampleService.findAll(companyCode, req.query);
|
||||
return res.json({ success: true, data });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 체크리스트
|
||||
|
||||
### 7.1 새로운 테이블 생성 시
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가
|
||||
- [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함
|
||||
|
||||
### 7.2 기존 테이블 마이그레이션 시
|
||||
|
||||
```sql
|
||||
-- 1. company_code 컬럼 추가
|
||||
ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20);
|
||||
|
||||
-- 2. 기존 데이터를 모든 회사별로 복제
|
||||
INSERT INTO example_table (company_code, name, description, created_at)
|
||||
SELECT ci.company_code, et.name, et.description, et.created_at
|
||||
FROM (SELECT * FROM example_table WHERE company_code IS NULL) et
|
||||
CROSS JOIN company_mng ci
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM example_table et2
|
||||
WHERE et2.name = et.name
|
||||
AND et2.company_code = ci.company_code
|
||||
);
|
||||
|
||||
-- 3. NULL 데이터 삭제
|
||||
DELETE FROM example_table WHERE company_code IS NULL;
|
||||
|
||||
-- 4. NOT NULL 제약조건
|
||||
ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL;
|
||||
|
||||
-- 5. 인덱스 및 외래키
|
||||
CREATE INDEX idx_example_company ON example_table(company_code);
|
||||
ALTER TABLE example_table
|
||||
ADD CONSTRAINT fk_example_company
|
||||
FOREIGN KEY (company_code) REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 체크리스트
|
||||
|
||||
### 8.1 필수 테스트 시나리오
|
||||
|
||||
**모든 새로운 API는 다음 테스트를 통과해야 합니다:**
|
||||
|
||||
- [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
|
||||
- [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
|
||||
- [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
|
||||
- [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
|
||||
- [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인
|
||||
- [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인
|
||||
|
||||
### 8.2 SQL 인젝션 테스트
|
||||
|
||||
```typescript
|
||||
// company_code를 URL 파라미터로 전달하려는 시도 차단
|
||||
// ❌ 이런 요청을 받아서는 안 됨
|
||||
GET /api/data?company_code=COMPANY_B
|
||||
|
||||
// ✅ company_code는 항상 req.user에서 가져와야 함
|
||||
const companyCode = req.user!.companyCode;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 감사 로그 (Audit Log)
|
||||
|
||||
### 9.1 모든 중요 작업에 로깅
|
||||
|
||||
```typescript
|
||||
logger.info("데이터 생성", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
tableName: "example_table",
|
||||
action: "INSERT",
|
||||
recordId: result.rows[0].id,
|
||||
});
|
||||
|
||||
logger.warn("권한 없는 접근 시도", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
attemptedRecordId: req.params.id,
|
||||
message: "다른 회사의 데이터 접근 시도",
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 감사 로그 테이블 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
table_name VARCHAR(100),
|
||||
record_id VARCHAR(100),
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_company ON audit_log(company_code);
|
||||
CREATE INDEX idx_audit_action ON audit_log(action, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 체크리스트 (코드 리뷰 시 필수)
|
||||
|
||||
### 10.1 백엔드 API 체크리스트
|
||||
|
||||
- [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외)
|
||||
- [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함
|
||||
- [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함
|
||||
- [ ] 서브쿼리에 `company_code` 필터링 포함
|
||||
- [ ] 집계 함수에 `company_code` 필터링 포함
|
||||
- [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
|
||||
- [ ] 로그에 `companyCode` 정보 포함
|
||||
- [ ] 권한 없음 시 404 또는 403 반환
|
||||
|
||||
### 10.2 프론트엔드 체크리스트
|
||||
|
||||
- [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달
|
||||
- [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리)
|
||||
- [ ] 에러 발생 시 적절한 메시지 표시
|
||||
|
||||
### 10.3 데이터베이스 체크리스트
|
||||
|
||||
- [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재
|
||||
- [ ] `company_code`에 NOT NULL 제약조건 적용
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 외래키 제약조건으로 `company_mng` 참조
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
|
||||
---
|
||||
|
||||
## 11. 일반적인 실수와 해결방법
|
||||
|
||||
### 실수 1: 서브쿼리에서 company_code 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 2: COUNT/SUM 집계 함수
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법 - 모든 회사의 총합
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 3: autoFilter 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
}
|
||||
);
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 참고 자료
|
||||
|
||||
### 완료된 구현 예시
|
||||
|
||||
- **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData)
|
||||
- **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins)
|
||||
- **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues)
|
||||
- **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData)
|
||||
- **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins)
|
||||
|
||||
### 마이그레이션 스크립트
|
||||
|
||||
- `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가
|
||||
- `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 요약: 절대 잊지 말아야 할 핵심 규칙
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외)
|
||||
2. **인덱스와 외래키 필수**
|
||||
3. **복합 유니크 제약조건에 `company_code` 포함**
|
||||
|
||||
### 백엔드 API
|
||||
|
||||
1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외)
|
||||
2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함
|
||||
3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함
|
||||
4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수
|
||||
2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리
|
||||
|
||||
---
|
||||
|
||||
**🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!**
|
||||
|
||||
**⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
**✅ 모든 테이블에 company_code 필수! (company_mng 제외)**
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 에이전트 필수 체크리스트
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:**
|
||||
|
||||
### 데이터베이스 마이그레이션을 작성했다면:
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가?
|
||||
- [ ] `company_code`에 인덱스를 생성했는가?
|
||||
- [ ] `company_mng` 테이블에 대한 외래키를 추가했는가?
|
||||
- [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가?
|
||||
- [ ] 기존 데이터를 모든 회사별로 복제했는가?
|
||||
|
||||
### 백엔드 API를 작성/수정했다면:
|
||||
|
||||
- [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외)
|
||||
- [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가?
|
||||
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가?
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가?
|
||||
- [ ] 서브쿼리에 `company_code` 필터링이 있는가?
|
||||
- [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가?
|
||||
- [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가?
|
||||
- [ ] 로그에 `companyCode` 정보를 포함했는가?
|
||||
- [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가?
|
||||
|
||||
### 프론트엔드 API 호출을 작성/수정했다면:
|
||||
|
||||
- [ ] `autoFilter` 옵션을 전달하고 있는가?
|
||||
- [ ] `autoFilter.enabled = true`로 설정했는가?
|
||||
- [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가?
|
||||
- [ ] `autoFilter.userField = "companyCode"`로 설정했는가?
|
||||
- [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리)
|
||||
|
||||
### 테스트를 수행했다면:
|
||||
|
||||
- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가?
|
||||
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가?
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!**
|
||||
|
||||
**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
# 다국어 지원 컴포넌트 개발 가이드
|
||||
|
||||
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
|
||||
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입 정의 시 다국어 필드 추가
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
|
||||
|
||||
### 단일 텍스트 속성
|
||||
|
||||
```typescript
|
||||
interface MyComponentConfig {
|
||||
// 기본 텍스트
|
||||
title?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
|
||||
// 라벨
|
||||
label?: string;
|
||||
labelLangKeyId?: number;
|
||||
labelLangKey?: string;
|
||||
|
||||
// 플레이스홀더
|
||||
placeholder?: string;
|
||||
placeholderLangKeyId?: number;
|
||||
placeholderLangKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 배열/목록 속성 (컬럼, 탭 등)
|
||||
|
||||
```typescript
|
||||
interface ColumnConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 기타 속성
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 탭 제목도 별도로
|
||||
title?: string;
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
}
|
||||
|
||||
interface MyComponentConfig {
|
||||
columns?: ColumnConfig[];
|
||||
tabs?: TabConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 컴포넌트
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 실제 예시: 분할 패널
|
||||
|
||||
```typescript
|
||||
interface SplitPanelLayoutConfig {
|
||||
leftPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 좌측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number; // 각 컬럼 다국어
|
||||
langKey?: string;
|
||||
}>;
|
||||
};
|
||||
rightPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 우측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
additionalTabs?: Array<{
|
||||
label: string;
|
||||
langKeyId?: number; // 탭 라벨 다국어
|
||||
langKey?: string;
|
||||
title?: string;
|
||||
titleLangKeyId?: number; // 탭 제목 다국어
|
||||
titleLangKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 라벨 추출 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractMultilangLabels` 함수에 추가
|
||||
|
||||
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 타입 체크
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 추출
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,-
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 컬럼 추출
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col, index) => {
|
||||
const colLabel = col.label || col.name;
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: colLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
|
||||
if (config?.text) {
|
||||
addLabel({
|
||||
id: `${comp.id}_button`,
|
||||
componentId: `${comp.id}_button`,
|
||||
label: config.text,
|
||||
type: "button",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.text,
|
||||
langKeyId: config.langKeyId,
|
||||
langKey: config.langKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 추출해야 할 라벨 타입
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| ------------- | ------------------ | ------------------------ |
|
||||
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
|
||||
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
|
||||
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
|
||||
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
|
||||
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
|
||||
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
|
||||
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
|
||||
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 매핑 적용 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `applyMultilangMappings` 함수에 추가
|
||||
|
||||
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 매핑 적용
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 매핑
|
||||
const titleMapping = mappingMap.get(`${comp.id}_title`);
|
||||
if (titleMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
titleLangKeyId: titleMapping.keyId,
|
||||
titleLangKey: titleMapping.langKey,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 컬럼 매핑
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
const updatedColumns = config.columns.map((col, index) => {
|
||||
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
|
||||
if (colMapping) {
|
||||
return {
|
||||
...col,
|
||||
langKeyId: colMapping.keyId,
|
||||
langKey: colMapping.langKey,
|
||||
};
|
||||
}
|
||||
return col;
|
||||
});
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
|
||||
const buttonMapping = mappingMap.get(`${comp.id}_button`);
|
||||
if (buttonMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: buttonMapping.keyId,
|
||||
langKey: buttonMapping.langKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
|
||||
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
|
||||
|
||||
```typescript
|
||||
// 잘못된 방법 - 이전 업데이트 덮어쓰기
|
||||
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
|
||||
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
|
||||
|
||||
// 올바른 방법 - 이전 업데이트 유지
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: mapping.keyId,
|
||||
}; // ✅
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
}; // ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 번역 표시 로직 구현
|
||||
|
||||
### 파일 위치
|
||||
|
||||
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
|
||||
|
||||
### Context 사용
|
||||
|
||||
```typescript
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
const MyComponent = ({ component }: Props) => {
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
const config = component.componentConfig;
|
||||
|
||||
// 제목 번역
|
||||
const displayTitle = config?.titleLangKey
|
||||
? getTranslatedText(config.titleLangKey, config.title || "")
|
||||
: config?.title || "";
|
||||
|
||||
// 컬럼 헤더 번역
|
||||
const translatedColumns = config?.columns?.map((col) => ({
|
||||
...col,
|
||||
displayLabel: col.langKey
|
||||
? getTranslatedText(col.langKey, col.label)
|
||||
: col.label,
|
||||
}));
|
||||
|
||||
// 버튼 텍스트 번역
|
||||
const buttonText = config?.langKey
|
||||
? getTranslatedText(config.langKey, config.text || "")
|
||||
: config?.text || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{displayTitle}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{translatedColumns?.map((col, idx) => (
|
||||
<th key={idx}>{col.displayLabel}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<button>{buttonText}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### getTranslatedText 함수
|
||||
|
||||
```typescript
|
||||
// 첫 번째 인자: langKey (다국어 키)
|
||||
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
|
||||
const text = getTranslatedText(
|
||||
"screen.company_1.Sales.OrderList.품목명",
|
||||
"품목명"
|
||||
);
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- `langKey`가 없으면 원본 텍스트를 표시합니다.
|
||||
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
|
||||
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. ScreenMultiLangContext에 키 수집 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/contexts/ScreenMultiLangContext.tsx`
|
||||
|
||||
### `collectLangKeys` 함수에 추가
|
||||
|
||||
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
|
||||
|
||||
```typescript
|
||||
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 langKey 수집
|
||||
if (comp.componentType === "my-new-component") {
|
||||
// 제목
|
||||
if (config?.titleLangKey) {
|
||||
keys.add(config.titleLangKey);
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col: any) => {
|
||||
if (col.langKey) {
|
||||
keys.add(col.langKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 버튼
|
||||
if (config?.langKey) {
|
||||
keys.add(config.langKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return keys;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. MultilangSettingsModal에 표시 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
|
||||
|
||||
### `extractLabelsFromComponents` 함수에 추가
|
||||
|
||||
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 라벨 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 제목
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns) {
|
||||
config.columns.forEach((col, index) => {
|
||||
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
|
||||
const tableName = config.tableName;
|
||||
const displayLabel =
|
||||
tableName && columnLabelMap[tableName]?.[col.name]
|
||||
? columnLabelMap[tableName][col.name]
|
||||
: col.label || col.name;
|
||||
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: displayLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractTableNames` 함수에 추가
|
||||
|
||||
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
|
||||
|
||||
```typescript
|
||||
const extractTableNames = (comps: ComponentData[]): Set<string> => {
|
||||
const tableNames = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 테이블명 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
if (config?.tableName) {
|
||||
tableNames.add(config.tableName);
|
||||
}
|
||||
if (config?.selectedTable) {
|
||||
tableNames.add(config.selectedTable);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return tableNames;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
|
||||
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
|
||||
|
||||
### 라벨 추출 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
|
||||
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
|
||||
|
||||
### 매핑 적용 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
|
||||
|
||||
### 번역 표시 (컴포넌트 파일)
|
||||
|
||||
- [ ] `useScreenMultiLang` 훅 사용
|
||||
- [ ] `getTranslatedText`로 텍스트 번역 적용
|
||||
|
||||
### 키 수집 (ScreenMultiLangContext.tsx)
|
||||
|
||||
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
|
||||
|
||||
### 설정 모달 (MultilangSettingsModal.tsx)
|
||||
|
||||
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일 목록
|
||||
|
||||
| 파일 | 역할 |
|
||||
| -------------------------------------------------------------- | ----------------------- |
|
||||
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
|
||||
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
|
||||
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
|
||||
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
|
||||
|
||||
- 제목: `${comp.id}_title`
|
||||
- 컬럼: `${comp.id}_col_${index}`
|
||||
- 버튼: `${comp.id}_button`
|
||||
|
||||
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
|
||||
|
||||
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
|
||||
|
||||
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
|
||||
|
||||
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
|
||||
|
||||
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
---
|
||||
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
|
||||
---
|
||||
|
||||
# 스크롤 문제 디버깅 및 해결 가이드
|
||||
|
||||
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
|
||||
|
||||
## 1. 스크롤 문제의 일반적인 원인
|
||||
|
||||
### 근본 원인: Flexbox의 높이 계산 실패
|
||||
|
||||
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
|
||||
|
||||
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
|
||||
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
|
||||
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
|
||||
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
|
||||
|
||||
## 2. 디버깅 프로세스
|
||||
|
||||
### 단계 1: 시각적 디버깅 (컬러 테두리)
|
||||
|
||||
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
|
||||
|
||||
```tsx
|
||||
// 최상위 컨테이너 (빨간색)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
border: "3px solid red", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (파란색) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
border: "3px solid blue", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 (초록색) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
border: "3px solid green", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
콘텐츠
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**브라우저에서 확인할 사항:**
|
||||
|
||||
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
|
||||
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
|
||||
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
|
||||
|
||||
### 단계 2: 부모 체인 추적
|
||||
|
||||
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
|
||||
|
||||
```tsx
|
||||
// ❌ 문제 예시
|
||||
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
|
||||
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 실패 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 해결
|
||||
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
|
||||
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 성공 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 단계 3: 개발자 도구로 Computed Style 확인
|
||||
|
||||
브라우저 개발자 도구에서 확인:
|
||||
|
||||
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
|
||||
2. **Display**: `flex`가 제대로 적용되었는가?
|
||||
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
|
||||
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
|
||||
|
||||
## 3. 해결 패턴
|
||||
|
||||
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
||||
|
||||
```tsx
|
||||
// 페이지 레벨 (예: dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 (flex-1) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{" "}
|
||||
{/* ⚠️ overflow-hidden 필수! */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `fixed inset-0`: 뷰포트 전체 차지
|
||||
- `flex h-full flex-col`: Flex column 레이아웃
|
||||
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
|
||||
|
||||
### 패턴 B: 중첩된 Flex 컨테이너
|
||||
|
||||
```tsx
|
||||
// 컴포넌트 레벨 (예: FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
|
||||
>
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1">캔버스</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex", // ⚠️ Flex 컨테이너 명시
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
|
||||
- `display: "flex"`: Flex 컨테이너 명시
|
||||
- `overflow: 'hidden'`: 자식 크기 제한
|
||||
|
||||
### 패턴 C: 스크롤 가능 영역
|
||||
|
||||
```tsx
|
||||
// 스크롤 영역 (예: PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (고정) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0, // ⚠️ 축소 방지
|
||||
height: "64px", // ⚠️ 명시적 높이
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1, // ⚠️ 남은 공간 차지
|
||||
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
|
||||
overflowY: "auto", // ⚠️ 세로 스크롤
|
||||
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
|
||||
}}
|
||||
>
|
||||
{/* 실제 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
|
||||
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
|
||||
- `flex: 1`: 남은 공간 모두 차지
|
||||
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
|
||||
|
||||
## 4. 왜 `minHeight: 0`이 필요한가?
|
||||
|
||||
### Flexbox의 기본 동작
|
||||
|
||||
```css
|
||||
/* Flexbox의 기본값 */
|
||||
.flex-item {
|
||||
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
|
||||
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
|
||||
- 결과: 스크롤 영역이 화면 밖으로 넘어감
|
||||
|
||||
**해결:**
|
||||
|
||||
```css
|
||||
.flex-item {
|
||||
flex: 1;
|
||||
min-height: 0; /* 축소 허용 → 스크롤 발생 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tailwind vs 인라인 스타일
|
||||
|
||||
### 언제 인라인 스타일을 사용하는가?
|
||||
|
||||
**Tailwind가 작동하지 않을 때:**
|
||||
|
||||
```tsx
|
||||
// ❌ Tailwind가 작동하지 않음
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
// ✅ 인라인 스타일로 강제
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
```
|
||||
|
||||
**이유:**
|
||||
|
||||
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
|
||||
2. **동적 계산**: 브라우저가 직접 해석
|
||||
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
|
||||
|
||||
## 6. 체크리스트
|
||||
|
||||
스크롤 문제 발생 시 확인할 사항:
|
||||
|
||||
### 레이아웃 체크
|
||||
|
||||
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
|
||||
- [ ] 부모: `flex flex-col h-full`
|
||||
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
|
||||
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
|
||||
|
||||
### 스크롤 영역 체크
|
||||
|
||||
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
|
||||
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
|
||||
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
|
||||
|
||||
### 디버깅 체크
|
||||
|
||||
- [ ] 컬러 테두리로 각 레이어의 크기 확인
|
||||
- [ ] 개발자 도구로 Computed Style 확인
|
||||
- [ ] 부모 체인을 역순으로 추적
|
||||
- [ ] `minHeight: 0` 적용 확인
|
||||
|
||||
## 7. 일반적인 실수
|
||||
|
||||
### 실수 1: 부모의 높이 미확정
|
||||
|
||||
```tsx
|
||||
// ❌ 부모의 높이가 auto
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 작동 안 함 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 부모의 높이 확정
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 작동 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 2: overflow-hidden 누락
|
||||
|
||||
```tsx
|
||||
// ❌ overflow-hidden 없음
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 부모를 밀어냄 */}
|
||||
</div>
|
||||
|
||||
// ✅ overflow-hidden 추가
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 제한됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 3: minHeight: 0 누락
|
||||
|
||||
```tsx
|
||||
// ❌ minHeight: 0 없음
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 스크롤 안 됨 */}
|
||||
</div>
|
||||
|
||||
// ✅ minHeight: 0 추가
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* 스크롤 됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 4: display: flex 누락
|
||||
|
||||
```tsx
|
||||
// ❌ Flex 컨테이너 미지정
|
||||
<div style={{ height: '100%', width: '350px' }}>
|
||||
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
|
||||
</div>
|
||||
|
||||
// ✅ Flex 컨테이너 명시
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '350px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<PropertiesPanel /> {/* 작동 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 8. 완전한 예시
|
||||
|
||||
### 전체 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 페이지 (dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 에디터 (FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{/* 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r">
|
||||
사이드바
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
캔버스
|
||||
</div>
|
||||
|
||||
{/* 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 속성 패널 (PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 9. 요약
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
|
||||
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
|
||||
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
|
||||
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
|
||||
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
|
||||
|
||||
### 디버깅 순서
|
||||
|
||||
1. 🎨 **컬러 테두리** 추가로 시각적 확인
|
||||
2. 🔍 **개발자 도구**로 Computed Style 확인
|
||||
3. 🔗 **부모 체인** 역순으로 추적
|
||||
4. ✅ **체크리스트** 항목 확인
|
||||
5. 🔧 **패턴 적용** 및 테스트
|
||||
|
||||
### 최종 구조
|
||||
|
||||
```
|
||||
페이지 (fixed inset-0)
|
||||
└─ flex flex-col h-full
|
||||
├─ 헤더 (고정)
|
||||
└─ 컨테이너 (flex-1 overflow-hidden)
|
||||
└─ 에디터 (height: 100%, overflow: hidden)
|
||||
└─ flex row
|
||||
└─ 패널 (display: flex, flexDirection: column)
|
||||
└─ 패널 내부 (height: 100%)
|
||||
├─ 헤더 (flexShrink: 0, height: 64px)
|
||||
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
|
||||
```
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
이 가이드는 다음 파일을 기반으로 작성되었습니다:
|
||||
|
||||
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
|
||||
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
|
||||
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
# TableListComponent 개발 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
|
||||
|
||||
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 핵심 기능 목록
|
||||
|
||||
### 1. 인라인 편집 (Inline Editing)
|
||||
|
||||
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
|
||||
- 직접 타이핑으로도 편집 모드 진입 가능
|
||||
- Enter로 저장, Escape로 취소
|
||||
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
|
||||
|
||||
```typescript
|
||||
// ColumnConfig에서 editable 속성 사용
|
||||
interface ColumnConfig {
|
||||
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
|
||||
}
|
||||
```
|
||||
|
||||
**편집 불가 컬럼 체크 필수 위치**:
|
||||
1. `handleCellDoubleClick` - 더블클릭 편집
|
||||
2. `onKeyDown` F2 케이스 - 키보드 편집
|
||||
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
|
||||
4. 컨텍스트 메뉴 "셀 편집" 옵션
|
||||
|
||||
### 2. 배치 편집 (Batch Editing)
|
||||
|
||||
- 여러 셀 수정 후 일괄 저장/취소
|
||||
- `pendingChanges` Map으로 변경사항 추적
|
||||
- 저장 전 유효성 검증
|
||||
|
||||
### 3. 데이터 유효성 검증 (Validation)
|
||||
|
||||
```typescript
|
||||
type ValidationRule = {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
customMessage?: string;
|
||||
validate?: (value: any, row: any) => string | null;
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 컬럼 헤더 필터 (Header Filter)
|
||||
|
||||
- 각 컬럼 헤더에 필터 아이콘
|
||||
- 고유값 목록에서 다중 선택 필터링
|
||||
- `headerFilters` Map으로 필터 상태 관리
|
||||
|
||||
### 5. 필터 빌더 (Filter Builder)
|
||||
|
||||
```typescript
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "equals" | "notEquals" | "contains" | "notContains" |
|
||||
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
|
||||
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterGroup {
|
||||
id: string;
|
||||
logic: "AND" | "OR";
|
||||
conditions: FilterCondition[];
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 검색 패널 (Search Panel)
|
||||
|
||||
- 전체 데이터 검색
|
||||
- 검색어 하이라이팅
|
||||
- `searchHighlights` Map으로 하이라이트 위치 관리
|
||||
|
||||
### 7. 엑셀 내보내기 (Excel Export)
|
||||
|
||||
- `xlsx` 라이브러리 사용
|
||||
- 현재 표시 데이터 또는 전체 데이터 내보내기
|
||||
|
||||
```typescript
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// 사용 예시
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
||||
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
|
||||
```
|
||||
|
||||
### 8. 클립보드 복사 (Copy to Clipboard)
|
||||
|
||||
- 선택된 행 또는 전체 데이터 복사
|
||||
- 탭 구분자로 엑셀 붙여넣기 호환
|
||||
|
||||
### 9. 컨텍스트 메뉴 (Context Menu)
|
||||
|
||||
- 우클릭으로 메뉴 표시
|
||||
- 셀 편집, 행 복사, 행 삭제 등 옵션
|
||||
- 편집 불가 컬럼은 "(잠김)" 표시
|
||||
|
||||
### 10. 키보드 네비게이션
|
||||
|
||||
| 키 | 동작 |
|
||||
|---|---|
|
||||
| Arrow Keys | 셀 이동 |
|
||||
| Tab | 다음 셀 |
|
||||
| Shift+Tab | 이전 셀 |
|
||||
| F2 | 편집 모드 |
|
||||
| Enter | 저장 후 아래로 이동 |
|
||||
| Escape | 편집 취소 |
|
||||
| Ctrl+C | 복사 |
|
||||
| Delete | 셀 값 삭제 |
|
||||
|
||||
### 11. 컬럼 리사이징
|
||||
|
||||
- 컬럼 헤더 경계 드래그로 너비 조절
|
||||
- `columnWidths` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 12. 컬럼 순서 변경
|
||||
|
||||
- 드래그 앤 드롭으로 컬럼 순서 변경
|
||||
- `columnOrder` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 13. 상태 영속성 (State Persistence)
|
||||
|
||||
```typescript
|
||||
// localStorage 키 패턴
|
||||
const stateKey = `tableState_${tableName}_${userId}`;
|
||||
|
||||
// 저장되는 상태
|
||||
interface TableState {
|
||||
columnWidths: Record<string, number>;
|
||||
columnOrder: string[];
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
frozenColumns: string[];
|
||||
columnVisibility: Record<string, boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 14. 그룹화 및 그룹 소계
|
||||
|
||||
```typescript
|
||||
interface GroupedData {
|
||||
groupKey: string;
|
||||
groupValues: Record<string, any>;
|
||||
items: any[];
|
||||
count: number;
|
||||
summary?: Record<string, { sum: number; avg: number; count: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### 15. 총계 요약 (Total Summary)
|
||||
|
||||
- 숫자 컬럼의 합계, 평균, 개수 표시
|
||||
- 테이블 하단에 요약 행 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 캐싱 전략
|
||||
|
||||
```typescript
|
||||
// 테이블 컬럼 캐시
|
||||
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
// API 호출 디바운싱
|
||||
const debouncedApiCall = <T extends any[], R>(
|
||||
key: string,
|
||||
fn: (...args: T) => Promise<R>,
|
||||
delay: number = 300
|
||||
) => { ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필수 Import
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import * as XLSX from "xlsx";
|
||||
import { toast } from "sonner";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 상태 (State)
|
||||
|
||||
```typescript
|
||||
// 데이터 관련
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 편집 관련
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
columnName: string;
|
||||
originalValue: any;
|
||||
} | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<string>("");
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
||||
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
||||
|
||||
// 필터 관련
|
||||
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||
const [globalSearchText, setGlobalSearchText] = useState("");
|
||||
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
|
||||
|
||||
// 컬럼 관련
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택 관련
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
|
||||
// 정렬 관련
|
||||
const [sortBy, setSortBy] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 편집 불가 컬럼 구현 체크리스트
|
||||
|
||||
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
|
||||
|
||||
- [ ] `column.editable === false` 체크 추가
|
||||
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
|
||||
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
|
||||
|
||||
```typescript
|
||||
// 표준 편집 불가 체크 패턴
|
||||
const column = visibleColumns.find((col) => col.columnName === columnName);
|
||||
if (column?.editable === false) {
|
||||
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 표시
|
||||
|
||||
### 편집 불가 컬럼 표시
|
||||
|
||||
```tsx
|
||||
// 헤더에 잠금 아이콘
|
||||
{column.editable === false && (
|
||||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
// 셀 배경색
|
||||
className={cn(
|
||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
|
||||
2. **useCallback 사용**: 이벤트 핸들러 함수들
|
||||
3. **디바운싱**: API 호출, 검색, 필터링
|
||||
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
|
||||
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
|
||||
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
|
||||
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
|
||||
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
|
||||
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
|
||||
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
|
||||
|
|
@ -0,0 +1,592 @@
|
|||
# 테이블 타입 관리 SQL 작성 가이드
|
||||
|
||||
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
|
||||
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
|
||||
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
|
||||
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
|
||||
|
||||
---
|
||||
|
||||
## 1. 테이블 생성 DDL 템플릿
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE "테이블명" (
|
||||
-- 시스템 기본 컬럼 (자동 포함)
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
||||
"컬럼1" varchar(500),
|
||||
"컬럼2" varchar(500),
|
||||
"컬럼3" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### 예시: 고객 테이블 생성
|
||||
|
||||
```sql
|
||||
CREATE TABLE "customer_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"customer_name" varchar(500),
|
||||
"customer_code" varchar(500),
|
||||
"phone" varchar(500),
|
||||
"email" varchar(500),
|
||||
"address" varchar(500),
|
||||
"status" varchar(500),
|
||||
"registration_date" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 메타데이터 테이블 등록
|
||||
|
||||
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
|
||||
|
||||
### 2.1 table_labels (테이블 메타데이터)
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.2 table_type_columns (컬럼 타입 정보)
|
||||
|
||||
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
|
||||
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.3 column_labels (레거시 호환용 - 필수)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
|
||||
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
|
||||
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Type 정의
|
||||
|
||||
### 지원되는 Input Type 목록
|
||||
|
||||
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
|
||||
| ---------- | ------------- | ------------ | -------------------- |
|
||||
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
|
||||
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
|
||||
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
|
||||
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
|
||||
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
|
||||
| `select` | 선택 목록 | VARCHAR(500) | Select |
|
||||
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
|
||||
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
|
||||
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
|
||||
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
|
||||
|
||||
### WebType → InputType 변환 규칙
|
||||
|
||||
```
|
||||
text, textarea, email, tel, url, password → text
|
||||
number, decimal → number
|
||||
date, datetime, time → date
|
||||
select, dropdown → select
|
||||
checkbox, boolean → checkbox
|
||||
radio → radio
|
||||
code → code
|
||||
entity → entity
|
||||
file → text
|
||||
button → text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Detail Settings 설정
|
||||
|
||||
### 4.1 Code 타입 (공통코드 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"codeCategory": "코드_카테고리_ID"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
|
||||
```
|
||||
|
||||
### 4.2 Entity 타입 (테이블 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"referenceTable": "참조_테이블명",
|
||||
"referenceColumn": "참조_컬럼명(보통 id)",
|
||||
"displayColumn": "표시할_컬럼명"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
|
||||
```
|
||||
|
||||
### 4.3 Select 타입 (정적 옵션)
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "label": "옵션1", "value": "value1" },
|
||||
{ "label": "옵션2", "value": "value2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 전체 예시: 주문 테이블 생성
|
||||
|
||||
### Step 1: DDL 실행
|
||||
|
||||
```sql
|
||||
CREATE TABLE "order_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"order_no" varchar(500),
|
||||
"order_date" varchar(500),
|
||||
"customer_id" varchar(500),
|
||||
"total_amount" varchar(500),
|
||||
"status" varchar(500),
|
||||
"notes" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### Step 2: table_labels 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### Step 3: table_type_columns 등록
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
|
||||
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
|
||||
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
|
||||
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
|
||||
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
### Step 4: column_labels 등록 (레거시 호환)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
|
||||
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
|
||||
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
|
||||
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
|
||||
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
|
||||
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
|
||||
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 컬럼 추가 시
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
|
||||
```
|
||||
|
||||
### 메타데이터 등록
|
||||
|
||||
```sql
|
||||
-- table_type_columns
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- column_labels
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 로그 테이블 생성 (선택사항)
|
||||
|
||||
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
|
||||
|
||||
### 7.1 로그 테이블 DDL 템플릿
|
||||
|
||||
```sql
|
||||
-- 로그 테이블 생성
|
||||
CREATE TABLE 테이블명_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
|
||||
original_id VARCHAR(100), -- 원본 테이블 PK 값
|
||||
changed_column VARCHAR(100), -- 변경된 컬럼명
|
||||
old_value TEXT, -- 변경 전 값
|
||||
new_value TEXT, -- 변경 후 값
|
||||
changed_by VARCHAR(50), -- 변경자 ID
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
||||
ip_address VARCHAR(50), -- 변경 요청 IP
|
||||
user_agent TEXT, -- User Agent
|
||||
full_row_before JSONB, -- 변경 전 전체 행
|
||||
full_row_after JSONB -- 변경 후 전체 행
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
|
||||
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
|
||||
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
|
||||
```
|
||||
|
||||
### 7.2 트리거 함수 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value
|
||||
USING OLD, NEW;
|
||||
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO 테이블명_log (
|
||||
operation_type, original_id, changed_column, old_value, new_value,
|
||||
changed_by, ip_address, full_row_before, full_row_after
|
||||
)
|
||||
VALUES (
|
||||
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
|
||||
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 7.3 트리거 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER 테이블명_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
|
||||
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
|
||||
```
|
||||
|
||||
### 7.4 로그 설정 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_log_config (
|
||||
original_table_name, log_table_name, trigger_name,
|
||||
trigger_function_name, is_active, created_by, created_at
|
||||
) VALUES (
|
||||
'테이블명', '테이블명_log', '테이블명_audit_trigger',
|
||||
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.5 table_labels에 use_log_table 플래그 설정
|
||||
|
||||
```sql
|
||||
UPDATE table_labels
|
||||
SET use_log_table = 'Y', updated_date = now()
|
||||
WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
### 7.6 전체 예시: order_info 로그 테이블 생성
|
||||
|
||||
```sql
|
||||
-- Step 1: 로그 테이블 생성
|
||||
CREATE TABLE order_info_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL,
|
||||
original_id VARCHAR(100),
|
||||
changed_column VARCHAR(100),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by VARCHAR(50),
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
full_row_before JSONB,
|
||||
full_row_after JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
|
||||
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
|
||||
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
|
||||
|
||||
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
|
||||
|
||||
-- Step 2: 트리거 함수 생성
|
||||
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'order_info' AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value USING OLD, NEW;
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
||||
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Step 3: 트리거 생성
|
||||
CREATE TRIGGER order_info_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON order_info
|
||||
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
|
||||
|
||||
-- Step 4: 로그 설정 등록
|
||||
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
|
||||
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
|
||||
|
||||
-- Step 5: table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
|
||||
```
|
||||
|
||||
### 7.7 로그 테이블 삭제
|
||||
|
||||
```sql
|
||||
-- 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
|
||||
|
||||
-- 트리거 함수 삭제
|
||||
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
|
||||
|
||||
-- 로그 테이블 삭제
|
||||
DROP TABLE IF EXISTS 테이블명_log;
|
||||
|
||||
-- 로그 설정 삭제
|
||||
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
|
||||
|
||||
-- table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
### 테이블 생성/수정 시 반드시 확인할 사항:
|
||||
|
||||
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
|
||||
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
|
||||
- [ ] `table_labels`에 테이블 메타데이터 등록
|
||||
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
|
||||
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
|
||||
- [ ] 기본 컬럼 display_order: -5 ~ -1
|
||||
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
|
||||
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
|
||||
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
|
||||
|
||||
### 로그 테이블 생성 시 확인할 사항 (선택):
|
||||
|
||||
- [ ] 로그 테이블 생성 (`테이블명_log`)
|
||||
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
|
||||
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
|
||||
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
|
||||
- [ ] `table_log_config`에 로그 설정 등록
|
||||
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 9. 금지 사항
|
||||
|
||||
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
|
||||
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
|
||||
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
|
||||
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
|
||||
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
|
||||
|
||||
---
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
|
||||
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
|
||||
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
|
||||
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
|
||||
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
# 고정 헤더 테이블 표준 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
|
||||
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
|
||||
|
||||
## 필수 구조
|
||||
|
||||
### 1. 기본 HTML 구조
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 1
|
||||
</TableHead>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 2
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{/* 데이터 행들 */}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. 필수 클래스 설명
|
||||
|
||||
#### 스크롤 컨테이너 (외부 div)
|
||||
|
||||
```tsx
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
```
|
||||
|
||||
**필수 요소:**
|
||||
|
||||
- `relative`: sticky positioning의 기준점
|
||||
- `overflow-auto`: 스크롤 활성화
|
||||
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
|
||||
|
||||
#### Table 컴포넌트
|
||||
|
||||
```tsx
|
||||
<Table noWrapper>
|
||||
```
|
||||
|
||||
**필수 props:**
|
||||
|
||||
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
|
||||
- 이것이 없으면 sticky header가 작동하지 않음
|
||||
|
||||
#### TableHead (헤더 셀)
|
||||
|
||||
```tsx
|
||||
className =
|
||||
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
**필수 클래스:**
|
||||
|
||||
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
|
||||
- `sticky top-0`: 상단 고정
|
||||
- `z-10`: 다른 요소 위에 표시
|
||||
- `border-b`: 하단 테두리
|
||||
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
|
||||
|
||||
### 3. 왼쪽 열 고정 (체크박스 등)
|
||||
|
||||
첫 번째 열도 고정하려면:
|
||||
|
||||
```tsx
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox />
|
||||
</TableHead>
|
||||
```
|
||||
|
||||
**z-index 규칙:**
|
||||
|
||||
- 왼쪽+상단 고정: `z-20`
|
||||
- 상단만 고정: `z-10`
|
||||
- 왼쪽만 고정: `z-10`
|
||||
- 일반 셀: z-index 없음
|
||||
|
||||
### 4. 완전한 예제 (체크박스 포함)
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 왼쪽 고정 체크박스 열 */}
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
|
||||
{/* 일반 헤더 열들 */}
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{/* 왼쪽 고정 체크박스 */}
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRow(index)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 데이터 셀들 */}
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2">
|
||||
{row[col]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 반응형 대응
|
||||
|
||||
### 모바일: 카드 뷰
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모바일: 카드 뷰 */
|
||||
}
|
||||
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
|
||||
<div className="space-y-2 p-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="bg-card rounded-md border p-3">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 데스크톱: 테이블 뷰 */
|
||||
}
|
||||
<div
|
||||
className="relative hidden overflow-auto sm:block"
|
||||
style={{ height: "450px" }}
|
||||
>
|
||||
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 자주하는 실수
|
||||
|
||||
### ❌ 잘못된 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 1. noWrapper 없음 - sticky 작동 안함 */
|
||||
}
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
</Table>;
|
||||
|
||||
{
|
||||
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
|
||||
}
|
||||
<TableHead className="sticky top-0">헤더</TableHead>;
|
||||
|
||||
{
|
||||
/* 3. relative 없음 - sticky 기준점 없음 */
|
||||
}
|
||||
<div className="overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
|
||||
}
|
||||
<div className="relative overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
### ✅ 올바른 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모든 필수 요소 포함 */
|
||||
}
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>...</TableBody>
|
||||
</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 높이 설정 가이드
|
||||
|
||||
### 권장 높이값
|
||||
|
||||
- **소형 리스트**: `300px` ~ `400px`
|
||||
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
|
||||
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
|
||||
|
||||
### 동적 높이 계산
|
||||
|
||||
```tsx
|
||||
// 화면 높이의 60%
|
||||
style={{ height: "60vh" }}
|
||||
|
||||
// 화면 높이 - 헤더/푸터 제외
|
||||
style={{ height: "calc(100vh - 250px)" }}
|
||||
|
||||
// 부모 요소 기준
|
||||
className="h-full overflow-auto"
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 1. 가상 스크롤 (대량 데이터)
|
||||
|
||||
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
|
||||
|
||||
```tsx
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 50, // 행 높이
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 페이지네이션
|
||||
|
||||
대량 데이터는 페이지 단위로 렌더링:
|
||||
|
||||
```tsx
|
||||
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
|
||||
```
|
||||
|
||||
## 접근성
|
||||
|
||||
### ARIA 레이블
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
role="region"
|
||||
aria-label="스크롤 가능한 데이터 테이블"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Table noWrapper aria-label="데이터 목록">
|
||||
{/* 테이블 내용 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 키보드 네비게이션
|
||||
|
||||
```tsx
|
||||
<TableRow
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 행 내용 */}
|
||||
</TableRow>
|
||||
```
|
||||
|
||||
## 다크 모드 대응
|
||||
|
||||
### 배경색
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 라이트/다크 모드 모두 대응 */
|
||||
}
|
||||
className = "bg-background"; // ✅ 권장
|
||||
|
||||
{
|
||||
/* 고정 색상 - 다크 모드 문제 */
|
||||
}
|
||||
className = "bg-white"; // ❌ 비권장
|
||||
```
|
||||
|
||||
### 그림자
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 다크 모드에서도 보이는 그림자 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
|
||||
|
||||
{
|
||||
/* 또는 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
|
||||
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
|
||||
|
||||
## 체크리스트
|
||||
|
||||
테이블 구현 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 외부 div에 `relative overflow-auto` 적용
|
||||
- [ ] 외부 div에 고정 높이 설정
|
||||
- [ ] `<Table noWrapper>` 사용
|
||||
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
|
||||
- [ ] TableHead에 `border-b shadow-[...]` 적용
|
||||
- [ ] 왼쪽 고정 열은 `z-20` 사용
|
||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||
- [ ] 다크 모드 호환 색상 사용
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -1,312 +0,0 @@
|
|||
# 카드 컴포넌트 기능 확장 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
테이블 리스트 컴포넌트의 고급 기능들(Entity 조인, 필터, 검색, 페이지네이션)을 카드 컴포넌트에도 적용하여 일관된 사용자 경험을 제공합니다.
|
||||
|
||||
## 🔍 현재 상태 분석
|
||||
|
||||
### ✅ 기존 기능
|
||||
|
||||
- 테이블 데이터를 카드 형태로 표시
|
||||
- 기본적인 컬럼 매핑 (제목, 부제목, 설명, 이미지)
|
||||
- 카드 레이아웃 설정 (행당 카드 수, 간격)
|
||||
- 설정 패널 존재
|
||||
|
||||
### ❌ 부족한 기능
|
||||
|
||||
- Entity 조인 기능
|
||||
- 필터 및 검색 기능
|
||||
- 페이지네이션
|
||||
- 코드 변환 기능
|
||||
- 정렬 기능
|
||||
|
||||
## 🎯 개발 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ⚡
|
||||
|
||||
#### 1.1 새로운 타입 정의 추가
|
||||
|
||||
```typescript
|
||||
// CardDisplayConfig 확장
|
||||
interface CardFilterConfig {
|
||||
enabled: boolean;
|
||||
quickSearch: boolean;
|
||||
showColumnSelector?: boolean;
|
||||
advancedFilter: boolean;
|
||||
filterableColumns: string[];
|
||||
}
|
||||
|
||||
interface CardPaginationConfig {
|
||||
enabled: boolean;
|
||||
pageSize: number;
|
||||
showSizeSelector: boolean;
|
||||
showPageInfo: boolean;
|
||||
pageSizeOptions: number[];
|
||||
}
|
||||
|
||||
interface CardSortConfig {
|
||||
enabled: boolean;
|
||||
defaultSort?: {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
sortableColumns: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 CardDisplayConfig 확장
|
||||
|
||||
- filter, pagination, sort 설정 추가
|
||||
- Entity 조인 관련 설정 추가
|
||||
- 코드 변환 관련 설정 추가
|
||||
|
||||
### Phase 2: 핵심 기능 구현 🚀
|
||||
|
||||
#### 2.1 Entity 조인 기능
|
||||
|
||||
- `useEntityJoinOptimization` 훅 적용
|
||||
- 조인된 컬럼 데이터 매핑
|
||||
- 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- 컬럼 메타정보 관리
|
||||
|
||||
#### 2.2 데이터 관리 로직
|
||||
|
||||
- 검색/필터/정렬이 적용된 데이터 로딩
|
||||
- 페이지네이션 처리
|
||||
- 실시간 검색 기능
|
||||
- 캐시 최적화
|
||||
|
||||
#### 2.3 상태 관리
|
||||
|
||||
```typescript
|
||||
// 새로운 상태 추가
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
```
|
||||
|
||||
### Phase 3: UI 컴포넌트 구현 🎨
|
||||
|
||||
#### 3.1 헤더 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-header">
|
||||
<h3>{tableConfig.title || tableLabel}</h3>
|
||||
<div className="search-controls">
|
||||
{/* 검색바 */}
|
||||
<Input placeholder="검색..." />
|
||||
{/* 검색 컬럼 선택기 */}
|
||||
<select>...</select>
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button>↻</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.2 카드 그리드 영역
|
||||
|
||||
```jsx
|
||||
<div
|
||||
className="card-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cardsPerRow}, 1fr)`,
|
||||
gap: `${cardSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{displayData.map((item, index) => (
|
||||
<Card key={index}>{/* 카드 내용 렌더링 */}</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.3 페이지네이션 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-pagination">
|
||||
<div>
|
||||
전체 {totalItems}건 중 {startItem}-{endItem} 표시
|
||||
</div>
|
||||
<div>
|
||||
<select>페이지 크기</select>
|
||||
<Button>◀◀</Button>
|
||||
<Button>◀</Button>
|
||||
<span>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button>▶</Button>
|
||||
<Button>▶▶</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Phase 4: 설정 패널 확장 ⚙️
|
||||
|
||||
#### 4.1 새 탭 추가
|
||||
|
||||
- **필터 탭**: 검색 및 필터 설정
|
||||
- **페이지네이션 탭**: 페이지 관련 설정
|
||||
- **정렬 탭**: 정렬 기본값 설정
|
||||
|
||||
#### 4.2 설정 옵션
|
||||
|
||||
```jsx
|
||||
// 필터 탭
|
||||
<TabsContent value="filter">
|
||||
<Checkbox>필터 기능 사용</Checkbox>
|
||||
<Checkbox>빠른 검색</Checkbox>
|
||||
<Checkbox>검색 컬럼 선택기 표시</Checkbox>
|
||||
<Checkbox>고급 필터</Checkbox>
|
||||
</TabsContent>
|
||||
|
||||
// 페이지네이션 탭
|
||||
<TabsContent value="pagination">
|
||||
<Checkbox>페이지네이션 사용</Checkbox>
|
||||
<Input label="페이지 크기" />
|
||||
<Checkbox>페이지 크기 선택기 표시</Checkbox>
|
||||
<Checkbox>페이지 정보 표시</Checkbox>
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
## 🛠️ 구현 우선순위
|
||||
|
||||
### 🟢 High Priority (1-2주)
|
||||
|
||||
1. **Entity 조인 기능**: 테이블 리스트의 로직 재사용
|
||||
2. **기본 검색 기능**: 검색바 및 실시간 검색
|
||||
3. **페이지네이션**: 카드 개수 제한 및 페이지 이동
|
||||
|
||||
### 🟡 Medium Priority (2-3주)
|
||||
|
||||
4. **고급 필터**: 컬럼별 필터 옵션
|
||||
5. **정렬 기능**: 컬럼별 정렬 및 상태 표시
|
||||
6. **검색 컬럼 선택기**: 특정 컬럼 검색 기능
|
||||
|
||||
### 🔵 Low Priority (3-4주)
|
||||
|
||||
7. **카드 뷰 옵션**: 그리드/리스트 전환
|
||||
8. **카드 크기 조절**: 동적 크기 조정
|
||||
9. **즐겨찾기 필터**: 자주 사용하는 필터 저장
|
||||
|
||||
## 📝 기술적 고려사항
|
||||
|
||||
### 재사용 가능한 코드
|
||||
|
||||
- `useEntityJoinOptimization` 훅
|
||||
- 필터 및 검색 로직
|
||||
- 페이지네이션 컴포넌트
|
||||
- 코드 캐시 시스템
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 가상화 스크롤 (대량 데이터)
|
||||
- 이미지 지연 로딩
|
||||
- 메모리 효율적인 렌더링
|
||||
- 디바운스된 검색
|
||||
|
||||
### 일관성 유지
|
||||
|
||||
- 테이블 리스트와 동일한 API
|
||||
- 동일한 설정 구조
|
||||
- 일관된 스타일링
|
||||
- 동일한 이벤트 핸들링
|
||||
|
||||
## 🗂️ 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx # 메인 컴포넌트 (수정)
|
||||
├── CardDisplayConfigPanel.tsx # 설정 패널 (수정)
|
||||
├── types.ts # 타입 정의 (수정)
|
||||
├── index.ts # 기본 설정 (수정)
|
||||
├── hooks/
|
||||
│ └── useCardDataManagement.ts # 데이터 관리 훅 (신규)
|
||||
├── components/
|
||||
│ ├── CardHeader.tsx # 헤더 컴포넌트 (신규)
|
||||
│ ├── CardGrid.tsx # 그리드 컴포넌트 (신규)
|
||||
│ ├── CardPagination.tsx # 페이지네이션 (신규)
|
||||
│ └── CardFilter.tsx # 필터 컴포넌트 (신규)
|
||||
└── utils/
|
||||
└── cardHelpers.ts # 유틸리티 함수 (신규)
|
||||
```
|
||||
|
||||
## ✅ 완료된 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ✅
|
||||
|
||||
- ✅ `CardFilterConfig`, `CardPaginationConfig`, `CardSortConfig` 타입 정의
|
||||
- ✅ `CardColumnConfig` 인터페이스 추가 (Entity 조인 지원)
|
||||
- ✅ `CardDisplayConfig` 확장 (새로운 기능들 포함)
|
||||
- ✅ 기본 설정 업데이트 (filter, pagination, sort 기본값)
|
||||
|
||||
### Phase 2: Entity 조인 기능 구현 ✅
|
||||
|
||||
- ✅ `useEntityJoinOptimization` 훅 적용
|
||||
- ✅ 컬럼 메타정보 관리 (`columnMeta` 상태)
|
||||
- ✅ 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- ✅ Entity 조인을 고려한 데이터 로딩 로직
|
||||
|
||||
### Phase 3: 새로운 UI 구조 구현 ✅
|
||||
|
||||
- ✅ 헤더 영역 (제목, 검색바, 컬럼 선택기, 새로고침)
|
||||
- ✅ 카드 그리드 영역 (반응형 그리드, 로딩/오류 상태)
|
||||
- ✅ 개별 카드 렌더링 (제목, 부제목, 설명, 추가 필드)
|
||||
- ✅ 푸터/페이지네이션 영역 (페이지 정보, 크기 선택, 네비게이션)
|
||||
- ✅ 검색 기능 (디바운스, 컬럼 선택)
|
||||
- ✅ 코드 값 포맷팅 (`formatCellValue`)
|
||||
|
||||
### Phase 4: 설정 패널 확장 ✅
|
||||
|
||||
- ✅ **탭 기반 UI 구조** - 5개 탭으로 체계적 분류
|
||||
- ✅ **일반 탭** - 기본 설정, 카드 레이아웃, 스타일 옵션
|
||||
- ✅ **매핑 탭** - 컬럼 매핑, 동적 표시 컬럼 관리
|
||||
- ✅ **필터 탭** - 검색 및 필터 설정 옵션
|
||||
- ✅ **페이징 탭** - 페이지 관련 설정 및 크기 옵션
|
||||
- ✅ **정렬 탭** - 정렬 기본값 설정
|
||||
- ✅ **Shadcn/ui 컴포넌트 적용** - 일관된 UI/UX
|
||||
|
||||
## 🎉 프로젝트 완료!
|
||||
|
||||
### 📊 최종 달성 결과
|
||||
|
||||
**🚀 100% 완료** - 모든 계획된 기능이 성공적으로 구현되었습니다!
|
||||
|
||||
#### ✅ 구현된 주요 기능들
|
||||
|
||||
1. **완전한 데이터 관리**: 테이블 리스트와 동일한 수준의 데이터 로딩, 검색, 필터링, 페이지네이션
|
||||
2. **Entity 조인 지원**: 관계형 데이터 조인 및 코드 변환 자동화
|
||||
3. **고급 검색**: 실시간 검색, 컬럼별 검색, 자동 컬럼 선택
|
||||
4. **완전한 설정 UI**: 5개 탭으로 분류된 직관적인 설정 패널
|
||||
5. **반응형 카드 그리드**: 설정 가능한 레이아웃과 스타일
|
||||
|
||||
#### 🎯 성능 및 사용성
|
||||
|
||||
- **성능 최적화**: 디바운스 검색, 배치 코드 로딩, 캐시 활용
|
||||
- **사용자 경험**: 로딩 상태, 오류 처리, 직관적인 UI
|
||||
- **일관성**: 테이블 리스트와 완전히 동일한 API 및 기능
|
||||
|
||||
#### 📁 완성된 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx ✅ 완전 재구현 (Entity 조인, 검색, 페이징)
|
||||
├── CardDisplayConfigPanel.tsx ✅ 5개 탭 기반 설정 패널
|
||||
├── types.ts ✅ 확장된 타입 시스템
|
||||
└── index.ts ✅ 업데이트된 기본 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🏆 최종 상태**: **완료** (100%)
|
||||
**🎯 목표 달성**: 테이블 리스트와 동일한 수준의 강력한 카드 컴포넌트 완성
|
||||
**⚡ 개발 기간**: 계획 대비 빠른 완료 (예상 3-4주 → 실제 1일)
|
||||
**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준
|
||||
|
||||
### 🔥 주요 성과
|
||||
|
||||
이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다!
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
# vexplor 프로젝트 NCP Kubernetes 배포 가이드
|
||||
|
||||
## 배포 환경
|
||||
- **Kubernetes 클러스터**: NCP Kubernetes
|
||||
- **네임스페이스**: apps
|
||||
- **GitOps 도구**: Argo CD (https://argocd.kpslp.kr)
|
||||
- **CI/CD**: Jenkins (Kaniko 빌드)
|
||||
- **컨테이너 레지스트리**: registry.kpslp.kr
|
||||
|
||||
## 전제 조건
|
||||
|
||||
### 1. GitLab 레포지토리
|
||||
- [x] 프로젝트 코드 레포: 이미 생성됨 (현재 레포)
|
||||
- [ ] Helm Charts 레포: `https://gitlab.kpslp.kr/root/helm-charts` 접근 권한 필요
|
||||
|
||||
### 2. 필요한 권한
|
||||
- [ ] GitLab 계정 및 레포지토리 접근 권한
|
||||
- [ ] Jenkins 프로젝트 생성 권한 또는 담당자 요청
|
||||
- [ ] Argo CD 접속 계정
|
||||
- [ ] Container Registry 푸시 권한
|
||||
|
||||
---
|
||||
|
||||
## 배포 단계
|
||||
|
||||
### Step 1: Helm Charts 레포지토리 설정
|
||||
|
||||
김욱동 책임님께 다음 사항을 요청하세요:
|
||||
|
||||
```
|
||||
안녕하세요.
|
||||
|
||||
vexplor 프로젝트 배포를 위해 다음 작업이 필요합니다:
|
||||
|
||||
1. helm-charts 레포지토리 접근 권한 부여
|
||||
- 레포지토리: https://gitlab.kpslp.kr/root/helm-charts
|
||||
- 현재 404 오류로 접근 불가
|
||||
- 계정: [본인 GitLab 사용자명]
|
||||
|
||||
2. values 파일 업로드
|
||||
- 첨부된 values_vexplor.yaml 파일을
|
||||
- kpslp/values_vexplor.yaml 경로에 업로드해주시거나
|
||||
- 업로드 방법을 안내해주세요
|
||||
|
||||
3. Jenkins 프로젝트 생성
|
||||
- 프로젝트명: vexplor
|
||||
- Git 레포지토리: [현재 프로젝트 GitLab URL]
|
||||
- Jenkinsfile: 프로젝트 루트에 이미 준비됨
|
||||
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
**첨부 파일**: `values_vexplor.yaml` (프로젝트 루트에 생성됨)
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Jenkins 프로젝트 등록
|
||||
|
||||
Jenkins에서 새 파이프라인 프로젝트를 생성합니다:
|
||||
|
||||
1. **Jenkins 접속** (URL은 담당자에게 문의)
|
||||
2. **New Item** 클릭
|
||||
3. **프로젝트명**: `vexplor`
|
||||
4. **Pipeline** 선택
|
||||
5. **Pipeline 설정**:
|
||||
- Definition: `Pipeline script from SCM`
|
||||
- SCM: `Git`
|
||||
- Repository URL: `[현재 프로젝트 GitLab URL]`
|
||||
- Credentials: `gitlab_userpass_root` (또는 담당자가 안내한 credential)
|
||||
- Branch: `*/main`
|
||||
- Script Path: `Jenkinsfile`
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Argo CD 애플리케이션 등록
|
||||
|
||||
1. **Argo CD 접속**: https://argocd.kpslp.kr
|
||||
|
||||
2. **New App 생성**:
|
||||
- **Application Name**: `vexplor`
|
||||
- **Project**: `default`
|
||||
- **Sync Policy**: `Automatic` (자동 배포) 또는 `Manual` (수동 배포)
|
||||
- **Auto-Create Namespace**: ✓ (체크)
|
||||
|
||||
3. **Source 설정**:
|
||||
- **Repository URL**: `https://gitlab.kpslp.kr/root/helm-charts`
|
||||
- **Revision**: `HEAD` 또는 `main`
|
||||
- **Path**: `kpslp`
|
||||
- **Helm Values**: `values_vexplor.yaml`
|
||||
|
||||
4. **Destination 설정**:
|
||||
- **Cluster URL**: `https://kubernetes.default.svc` (기본값)
|
||||
- **Namespace**: `apps`
|
||||
|
||||
5. **Create** 클릭
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 첫 배포 실행
|
||||
|
||||
#### 4-1. Git Push로 Jenkins 빌드 트리거
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: NCP Kubernetes 배포 설정 완료"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
#### 4-2. Jenkins 빌드 모니터링
|
||||
1. Jenkins에서 `vexplor` 프로젝트 열기
|
||||
2. 빌드 시작 확인 (자동 트리거 또는 수동 빌드)
|
||||
3. 로그 확인:
|
||||
- **Checkout**: Git 소스 다운로드
|
||||
- **Build**: Docker 이미지 빌드 (`registry.kpslp.kr/slp/vexplor:xxxxx`)
|
||||
- **Update Image Tag**: helm-charts 레포의 values 파일 업데이트
|
||||
|
||||
#### 4-3. Argo CD 배포 확인
|
||||
1. Argo CD 대시보드에서 `vexplor` 앱 열기
|
||||
2. **Sync Status**: `OutOfSync` → `Synced` 변경 확인
|
||||
3. **Health Status**: `Progressing` → `Healthy` 변경 확인
|
||||
4. Pod 상태 확인 (Running 상태여야 함)
|
||||
|
||||
---
|
||||
|
||||
## 배포 후 확인사항
|
||||
|
||||
### 1. Pod 상태 확인
|
||||
```bash
|
||||
kubectl get pods -n apps | grep vexplor
|
||||
```
|
||||
**예상 출력**:
|
||||
```
|
||||
vexplor-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
|
||||
```
|
||||
|
||||
### 2. 서비스 확인
|
||||
```bash
|
||||
kubectl get svc -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 3. Ingress 확인
|
||||
```bash
|
||||
kubectl get ingress -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 4. 로그 확인
|
||||
```bash
|
||||
# 전체 로그
|
||||
kubectl logs -n apps -l app=vexplor
|
||||
|
||||
# 최근 50줄
|
||||
kubectl logs -n apps -l app=vexplor --tail=50
|
||||
|
||||
# 실시간 로그 (스트리밍)
|
||||
kubectl logs -n apps -l app=vexplor -f
|
||||
```
|
||||
|
||||
### 5. 애플리케이션 접속
|
||||
- **URL**: `https://vexplor.kpslp.kr` (values 파일에 설정한 도메인)
|
||||
- **헬스체크**: `https://vexplor.kpslp.kr/api/health`
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 문제 1: Jenkins 빌드 실패
|
||||
**증상**: Build 단계에서 에러 발생
|
||||
|
||||
**확인사항**:
|
||||
- Docker 이미지 빌드 로그 확인
|
||||
- `Dockerfile`이 프로젝트 루트에 있는지 확인
|
||||
- 빌드 컨텍스트에 필요한 파일들이 있는지 확인
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 로컬에서 Docker 빌드 테스트
|
||||
docker build -f Dockerfile -t vexplor:test .
|
||||
```
|
||||
|
||||
### 문제 2: helm-charts 레포 푸시 실패
|
||||
**증상**: Update Image Tag 단계에서 실패
|
||||
|
||||
**원인**: `gitlab_userpass_root` credential 문제 또는 권한 부족
|
||||
|
||||
**해결**: 김욱동 책임님께 credential 확인 요청
|
||||
|
||||
### 문제 3: Argo CD Sync 실패
|
||||
**증상**: `OutOfSync` 상태에서 변경 없음
|
||||
|
||||
**확인사항**:
|
||||
- values 파일이 올바른 경로에 있는지 (`kpslp/values_vexplor.yaml`)
|
||||
- Argo CD가 helm-charts 레포를 읽을 수 있는지
|
||||
|
||||
**해결**: Argo CD에서 수동 Sync 시도 또는 담당자에게 문의
|
||||
|
||||
### 문제 4: Pod가 CrashLoopBackOff 상태
|
||||
**증상**: Pod가 계속 재시작됨
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
kubectl describe pod -n apps [pod-name]
|
||||
kubectl logs -n apps [pod-name] --previous
|
||||
```
|
||||
|
||||
**일반적인 원인**:
|
||||
- 환경 변수 누락 (DATABASE_HOST 등)
|
||||
- 데이터베이스 연결 실패
|
||||
- 포트 바인딩 문제
|
||||
|
||||
**해결**:
|
||||
1. `values_vexplor.yaml`의 `env` 섹션 확인
|
||||
2. 데이터베이스 서비스명 확인 (`postgres-service.apps.svc.cluster.local`)
|
||||
3. Secret 설정 확인 (DB 비밀번호 등)
|
||||
|
||||
---
|
||||
|
||||
## 업데이트 배포 프로세스
|
||||
|
||||
코드 수정 후 배포 절차:
|
||||
|
||||
```bash
|
||||
# 1. 코드 수정
|
||||
git add .
|
||||
git commit -m "feat: 새로운 기능 추가"
|
||||
git push origin main
|
||||
|
||||
# 2. Jenkins 자동 빌드 (자동 트리거)
|
||||
# - Git push 감지
|
||||
# - Docker 이미지 빌드
|
||||
# - 새 이미지 태그로 values 파일 업데이트
|
||||
|
||||
# 3. Argo CD 자동 배포 (Sync Policy가 Automatic인 경우)
|
||||
# - helm-charts 레포 변경 감지
|
||||
# - Kubernetes에 새 이미지 배포
|
||||
# - Rolling Update 수행
|
||||
```
|
||||
|
||||
**수동 배포**: Argo CD 대시보드에서 `Sync` 버튼 클릭
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
배포 전 확인사항:
|
||||
|
||||
- [ ] Jenkinsfile 수정 완료 (단일 이미지 빌드)
|
||||
- [ ] Dockerfile 확인 (멀티스테이지 빌드)
|
||||
- [ ] values_vexplor.yaml 작성 및 업로드
|
||||
- [ ] Jenkins 프로젝트 생성
|
||||
- [ ] Argo CD 애플리케이션 등록
|
||||
- [ ] 환경 변수 설정 (DATABASE_HOST 등)
|
||||
- [ ] Secret 생성 (DB 비밀번호 등)
|
||||
- [ ] Ingress 도메인 설정
|
||||
- [ ] 헬스체크 엔드포인트 확인 (`/api/health`)
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **Kaniko**: 컨테이너 내에서 Docker 이미지를 빌드하는 도구
|
||||
- **GitOps**: Git을 Single Source of Truth로 사용하는 배포 방식
|
||||
- **Argo CD**: GitOps를 위한 Kubernetes CD 도구
|
||||
- **Helm**: Kubernetes 패키지 매니저
|
||||
|
||||
---
|
||||
|
||||
## 담당자 연락처
|
||||
|
||||
- **NCP 클러스터 관리**: 김욱동 책임 (엘에스티라유텍)
|
||||
- **Bastion 서버**: 223.130.135.25:22 (Docker 직접 배포용 아님)
|
||||
- **Argo CD**: https://argocd.kpslp.kr
|
||||
- **Kubernetes 네임스페이스**: apps
|
||||
|
||||
---
|
||||
|
||||
## 추가 설정 (선택사항)
|
||||
|
||||
### PostgreSQL 데이터베이스 설정
|
||||
클러스터 내부에 PostgreSQL이 없다면:
|
||||
|
||||
```yaml
|
||||
# values_vexplor.yaml 에 추가
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
username: vexplor
|
||||
password: changeme123 # Secret으로 관리 권장
|
||||
database: vexplor
|
||||
primary:
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
```
|
||||
|
||||
### Secret 생성 (민감 정보)
|
||||
```bash
|
||||
kubectl create secret generic vexplor-secrets \
|
||||
--from-literal=db-password='your-secure-password' \
|
||||
--from-literal=jwt-secret='your-jwt-secret' \
|
||||
-n apps
|
||||
```
|
||||
|
||||
### 모니터링 (Prometheus + Grafana)
|
||||
담당자에게 메트릭 수집 설정 요청
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# ==========================
|
||||
# 멀티 스테이지 Dockerfile
|
||||
# - 백엔드: Node.js + Express + TypeScript
|
||||
# - 프론트엔드: Next.js (프로덕션 빌드)
|
||||
# ==========================
|
||||
|
||||
# ------------------------------
|
||||
# Stage 1: 백엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend-node/package*.json ./
|
||||
RUN npm ci --only=production && \
|
||||
npm cache clean --force
|
||||
|
||||
# 백엔드 소스 복사 및 빌드
|
||||
COPY backend-node/tsconfig.json ./
|
||||
COPY backend-node/src ./src
|
||||
RUN npm install -D typescript @types/node && \
|
||||
npm run build && \
|
||||
npm prune --production
|
||||
|
||||
# ------------------------------
|
||||
# Stage 2: 프론트엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# 프론트엔드 의존성 설치
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci && \
|
||||
npm cache clean --force
|
||||
|
||||
# 프론트엔드 소스 복사
|
||||
COPY frontend/ ./
|
||||
|
||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build:no-lint
|
||||
|
||||
# ------------------------------
|
||||
# Stage 3: 최종 런타임 이미지
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS runtime
|
||||
|
||||
# 보안 강화: 비특권 사용자 생성
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 백엔드 런타임 파일 복사
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/dist ./backend/dist
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/node_modules ./backend/node_modules
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/package.json ./backend/package.json
|
||||
|
||||
# 프론트엔드 런타임 파일 복사
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/.next ./frontend/.next
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./frontend/package.json
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||
|
||||
# 업로드 디렉토리 생성 (백엔드용)
|
||||
RUN mkdir -p /app/backend/uploads && \
|
||||
chown -R nodejs:nodejs /app/backend/uploads
|
||||
|
||||
# 시작 스크립트 생성
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
echo 'set -e' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/backend' >> /app/start.sh && \
|
||||
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
||||
echo 'node dist/app.js &' >> /app/start.sh && \
|
||||
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||
echo 'npm start &' >> /app/start.sh && \
|
||||
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프로세스 모니터링' >> /app/start.sh && \
|
||||
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh && \
|
||||
chown nodejs:nodejs /app/start.sh
|
||||
|
||||
# 비특권 사용자로 전환
|
||||
USER nodejs
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000 8080
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# 컨테이너 시작
|
||||
CMD ["/app/start.sh"]
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
pipeline {
|
||||
agent {
|
||||
label "kaniko"
|
||||
}
|
||||
stages {
|
||||
stage("Checkout") {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||
env.GIT_AUTHOR_NAME = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true)
|
||||
env.GIT_AUTHOR_EMAIL = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true)
|
||||
env.GIT_COMMIT_MESSAGE = sh (script: 'git log -1 --pretty=%B ${GIT_COMMIT}', returnStdout: true).trim()
|
||||
env.GIT_PROJECT_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-2]
|
||||
env.GIT_REPO_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Build") {
|
||||
steps {
|
||||
container("kaniko") {
|
||||
script {
|
||||
sh "/kaniko/executor --context . --destination registry.kpslp.kr/${GIT_PROJECT_NAME}/${GIT_REPO_NAME}:${GIT_COMMIT_SHORT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Update Image Tag") {
|
||||
steps {
|
||||
deleteDir()
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: '*/main']],
|
||||
extensions: [],
|
||||
userRemoteConfigs: [[credentialsId: 'gitlab_userpass_root', url: "https://gitlab.kpslp.kr/root/helm-charts"]]
|
||||
])
|
||||
script {
|
||||
def valuesYaml = "kpslp/values_${GIT_REPO_NAME}.yaml"
|
||||
def values = readYaml file: "${valuesYaml}"
|
||||
values.image.tag = env.GIT_COMMIT_SHORT
|
||||
writeYaml file: "${valuesYaml}", data: values, overwrite: true
|
||||
|
||||
sh "git config user.name '${GIT_AUTHOR_NAME}'"
|
||||
sh "git config user.email '${GIT_AUTHOR_EMAIL}'"
|
||||
withCredentials([usernameColonPassword(credentialsId: 'gitlab_userpass_root', variable: 'USERPASS')]) {
|
||||
sh '''
|
||||
git add . && \
|
||||
git commit -m "${GIT_REPO_NAME}: ${GIT_COMMIT_MESSAGE}" && \
|
||||
git push https://${USERPASS}@gitlab.kpslp.kr/root/helm-charts HEAD:main || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,733 +0,0 @@
|
|||
# 🔐 Phase 1.5: 인증 및 관리자 서비스 Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
Phase 2의 핵심 서비스 전환 전에 **인증 및 관리자 시스템**을 먼저 Raw Query로 전환하여 전체 시스템의 안정적인 기반을 구축합니다.
|
||||
|
||||
### 🎯 목표
|
||||
|
||||
- AuthService의 5개 Prisma 호출 제거
|
||||
- AdminService의 3개 Prisma 호출 제거 (이미 Raw Query 사용 중)
|
||||
- AdminController의 28개 Prisma 호출 제거
|
||||
- 로그인 → 인증 → API 호출 전체 플로우 검증
|
||||
|
||||
### 📊 전환 대상
|
||||
|
||||
| 서비스 | Prisma 호출 수 | 복잡도 | 우선순위 |
|
||||
|--------|----------------|--------|----------|
|
||||
| AuthService | 5개 | 중간 | 🔴 최우선 |
|
||||
| AdminService | 3개 | 낮음 (이미 Raw Query) | 🟢 확인만 필요 |
|
||||
| AdminController | 28개 | 중간 | 🟡 2순위 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 AuthService 분석
|
||||
|
||||
### Prisma 사용 현황 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 21: loginPwdCheck() - 사용자 비밀번호 조회
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { user_password: true },
|
||||
});
|
||||
|
||||
// Line 82: insertLoginAccessLog() - 로그인 로그 기록
|
||||
await prisma.$executeRaw`INSERT INTO LOGIN_ACCESS_LOG(...)`;
|
||||
|
||||
// Line 126: getUserInfo() - 사용자 정보 조회
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { /* 20개 필드 */ },
|
||||
});
|
||||
|
||||
// Line 157: getUserInfo() - 권한 정보 조회
|
||||
const authInfo = await prisma.authority_sub_user.findMany({
|
||||
where: { user_id: userId },
|
||||
include: { authority_master: { select: { auth_name: true } } },
|
||||
});
|
||||
|
||||
// Line 177: getUserInfo() - 회사 정보 조회
|
||||
const companyInfo = await prisma.company_mng.findFirst({
|
||||
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||
select: { company_name: true },
|
||||
});
|
||||
```
|
||||
|
||||
### 핵심 메서드
|
||||
|
||||
1. **loginPwdCheck()** - 로그인 비밀번호 검증
|
||||
- user_info 테이블 조회
|
||||
- 비밀번호 암호화 비교
|
||||
- 마스터 패스워드 체크
|
||||
|
||||
2. **insertLoginAccessLog()** - 로그인 이력 기록
|
||||
- LOGIN_ACCESS_LOG 테이블 INSERT
|
||||
- Raw Query 이미 사용 중 (유지)
|
||||
|
||||
3. **getUserInfo()** - 사용자 상세 정보 조회
|
||||
- user_info 테이블 조회 (20개 필드)
|
||||
- authority_sub_user + authority_master 조인 (권한)
|
||||
- company_mng 테이블 조회 (회사명)
|
||||
- PersonBean 타입 변환
|
||||
|
||||
4. **processLogin()** - 로그인 전체 프로세스
|
||||
- 위 3개 메서드 조합
|
||||
- JWT 토큰 생성
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 계획
|
||||
|
||||
### Step 1: loginPwdCheck() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { user_password: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
const result = await query<{ user_password: string }>(
|
||||
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
```
|
||||
|
||||
### Step 2: getUserInfo() 전환 (사용자 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: {
|
||||
sabun: true,
|
||||
user_id: true,
|
||||
user_name: true,
|
||||
// ... 20개 필드
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const result = await query<{
|
||||
sabun: string | null;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng: string | null;
|
||||
user_name_cn: string | null;
|
||||
dept_code: string | null;
|
||||
dept_name: string | null;
|
||||
position_code: string | null;
|
||||
position_name: string | null;
|
||||
email: string | null;
|
||||
tel: string | null;
|
||||
cell_phone: string | null;
|
||||
user_type: string | null;
|
||||
user_type_name: string | null;
|
||||
partner_objid: string | null;
|
||||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
```
|
||||
|
||||
### Step 3: getUserInfo() 전환 (권한 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const authInfo = await prisma.authority_sub_user.findMany({
|
||||
where: { user_id: userId },
|
||||
include: {
|
||||
authority_master: {
|
||||
select: { auth_name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authNames = authInfo
|
||||
.filter((auth: any) => auth.authority_master?.auth_name)
|
||||
.map((auth: any) => auth.authority_master!.auth_name!)
|
||||
.join(",");
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const authResult = await query<{ auth_name: string }>(
|
||||
`SELECT am.auth_name
|
||||
FROM authority_sub_user asu
|
||||
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||
WHERE asu.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||
```
|
||||
|
||||
### Step 4: getUserInfo() 전환 (회사 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const companyInfo = await prisma.company_mng.findFirst({
|
||||
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||
select: { company_name: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const companyResult = await query<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[userInfo.company_code || "ILSHIN"]
|
||||
);
|
||||
|
||||
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 완전 전환된 AuthService 코드
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* 로그인 비밀번호 검증 (Raw Query 전환)
|
||||
*/
|
||||
static async loginPwdCheck(
|
||||
userId: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
try {
|
||||
// Raw Query로 사용자 비밀번호 조회
|
||||
const result = await query<{ user_password: string }>(
|
||||
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
|
||||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
|
||||
// 마스터 패스워드 체크
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return { loginResult: true };
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
if (EncryptUtil.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return { loginResult: true };
|
||||
} else {
|
||||
logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "패스워드가 일치하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.warn(`사용자가 존재하지 않음: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "사용자가 존재하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 로그 기록 (이미 Raw Query 사용 - 유지)
|
||||
*/
|
||||
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO LOGIN_ACCESS_LOG(
|
||||
LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
|
||||
REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
|
||||
) VALUES (
|
||||
now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
|
||||
)`,
|
||||
[
|
||||
logData.systemName,
|
||||
logData.userId,
|
||||
logData.loginResult,
|
||||
logData.errorMessage || null,
|
||||
logData.remoteAddr,
|
||||
logData.recptnDt || null,
|
||||
logData.recptnRsltDtl || null,
|
||||
logData.recptnRslt || null,
|
||||
logData.recptnRsltCd || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
// 로그 기록 실패는 로그인 프로세스를 중단하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 조회 (Raw Query 전환)
|
||||
*/
|
||||
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
// 1. 사용자 기본 정보 조회
|
||||
const userResult = await query<{
|
||||
sabun: string | null;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng: string | null;
|
||||
user_name_cn: string | null;
|
||||
dept_code: string | null;
|
||||
dept_name: string | null;
|
||||
position_code: string | null;
|
||||
position_name: string | null;
|
||||
email: string | null;
|
||||
tel: string | null;
|
||||
cell_phone: string | null;
|
||||
user_type: string | null;
|
||||
user_type_name: string | null;
|
||||
partner_objid: string | null;
|
||||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = userResult.length > 0 ? userResult[0] : null;
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 권한 정보 조회 (JOIN으로 최적화)
|
||||
const authResult = await query<{ auth_name: string }>(
|
||||
`SELECT am.auth_name
|
||||
FROM authority_sub_user asu
|
||||
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||
WHERE asu.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||
|
||||
// 3. 회사 정보 조회
|
||||
const companyResult = await query<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[userInfo.company_code || "ILSHIN"]
|
||||
);
|
||||
|
||||
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||
|
||||
// PersonBean 형태로 변환
|
||||
const personBean: PersonBean = {
|
||||
userId: userInfo.user_id,
|
||||
userName: userInfo.user_name || "",
|
||||
userNameEng: userInfo.user_name_eng || undefined,
|
||||
userNameCn: userInfo.user_name_cn || undefined,
|
||||
deptCode: userInfo.dept_code || undefined,
|
||||
deptName: userInfo.dept_name || undefined,
|
||||
positionCode: userInfo.position_code || undefined,
|
||||
positionName: userInfo.position_name || undefined,
|
||||
email: userInfo.email || undefined,
|
||||
tel: userInfo.tel || undefined,
|
||||
cellPhone: userInfo.cell_phone || undefined,
|
||||
userType: userInfo.user_type || undefined,
|
||||
userTypeName: userInfo.user_type_name || undefined,
|
||||
partnerObjid: userInfo.partner_objid || undefined,
|
||||
authName: authNames || undefined,
|
||||
companyCode: userInfo.company_code || "ILSHIN",
|
||||
photo: userInfo.photo
|
||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
};
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰으로 사용자 정보 조회
|
||||
*/
|
||||
static async getUserInfoFromToken(token: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 프로세스 전체 처리
|
||||
*/
|
||||
static async processLogin(
|
||||
userId: string,
|
||||
password: string,
|
||||
remoteAddr: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
userInfo?: PersonBean;
|
||||
token?: string;
|
||||
errorReason?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 로그인 검증
|
||||
const loginResult = await this.loginPwdCheck(userId, password);
|
||||
|
||||
// 2. 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: loginResult.loginResult,
|
||||
errorMessage: loginResult.errorReason,
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
|
||||
if (loginResult.loginResult) {
|
||||
// 3. 사용자 정보 조회
|
||||
const userInfo = await this.getUserInfo(userId);
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "사용자 정보를 조회할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. JWT 토큰 생성
|
||||
const token = JwtUtils.generateToken(userInfo);
|
||||
|
||||
logger.info(`로그인 성공: ${userId} (${remoteAddr})`);
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
token,
|
||||
};
|
||||
} else {
|
||||
logger.warn(
|
||||
`로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: loginResult.errorReason,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 프로세스 처리
|
||||
*/
|
||||
static async processLogout(
|
||||
userId: string,
|
||||
remoteAddr: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 로그아웃 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: false,
|
||||
errorMessage: "로그아웃",
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/tests/authService.test.ts
|
||||
import { AuthService } from "../services/authService";
|
||||
import { query } from "../database/db";
|
||||
|
||||
describe("AuthService Raw Query 전환 테스트", () => {
|
||||
describe("loginPwdCheck", () => {
|
||||
test("존재하는 사용자 로그인 성공", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "testpass");
|
||||
expect(result.loginResult).toBe(true);
|
||||
});
|
||||
|
||||
test("존재하지 않는 사용자 로그인 실패", async () => {
|
||||
const result = await AuthService.loginPwdCheck("nonexistent", "password");
|
||||
expect(result.loginResult).toBe(false);
|
||||
expect(result.errorReason).toContain("존재하지 않습니다");
|
||||
});
|
||||
|
||||
test("잘못된 비밀번호 로그인 실패", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "wrongpass");
|
||||
expect(result.loginResult).toBe(false);
|
||||
expect(result.errorReason).toContain("일치하지 않습니다");
|
||||
});
|
||||
|
||||
test("마스터 패스워드 로그인 성공", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "qlalfqjsgh11");
|
||||
expect(result.loginResult).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserInfo", () => {
|
||||
test("사용자 정보 조회 성공", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("testuser");
|
||||
expect(userInfo).not.toBeNull();
|
||||
expect(userInfo?.userId).toBe("testuser");
|
||||
expect(userInfo?.userName).toBeDefined();
|
||||
});
|
||||
|
||||
test("권한 정보 조회 성공", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("testuser");
|
||||
expect(userInfo?.authName).toBeDefined();
|
||||
});
|
||||
|
||||
test("존재하지 않는 사용자 조회 실패", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("nonexistent");
|
||||
expect(userInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processLogin", () => {
|
||||
test("전체 로그인 프로세스 성공", async () => {
|
||||
const result = await AuthService.processLogin(
|
||||
"testuser",
|
||||
"testpass",
|
||||
"127.0.0.1"
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.userInfo).toBeDefined();
|
||||
});
|
||||
|
||||
test("로그인 실패 시 토큰 없음", async () => {
|
||||
const result = await AuthService.processLogin(
|
||||
"testuser",
|
||||
"wrongpass",
|
||||
"127.0.0.1"
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.token).toBeUndefined();
|
||||
expect(result.errorReason).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertLoginAccessLog", () => {
|
||||
test("로그인 로그 기록 성공", async () => {
|
||||
await expect(
|
||||
AuthService.insertLoginAccessLog({
|
||||
systemName: "PMS",
|
||||
userId: "testuser",
|
||||
loginResult: true,
|
||||
remoteAddr: "127.0.0.1",
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/tests/integration/auth.integration.test.ts
|
||||
import request from "supertest";
|
||||
import app from "../../app";
|
||||
|
||||
describe("인증 시스템 통합 테스트", () => {
|
||||
let authToken: string;
|
||||
|
||||
test("POST /api/auth/login - 로그인 성공", async () => {
|
||||
const response = await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({
|
||||
userId: "testuser",
|
||||
password: "testpass",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.token).toBeDefined();
|
||||
expect(response.body.userInfo).toBeDefined();
|
||||
|
||||
authToken = response.body.token;
|
||||
});
|
||||
|
||||
test("GET /api/auth/verify - 토큰 검증 성공", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/verify")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.valid).toBe(true);
|
||||
expect(response.body.userInfo).toBeDefined();
|
||||
});
|
||||
|
||||
test("GET /api/admin/menu - 인증된 사용자 메뉴 조회", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/admin/menu")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
||||
test("POST /api/auth/logout - 로그아웃 성공", async () => {
|
||||
await request(app)
|
||||
.post("/api/auth/logout")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### AuthService 전환
|
||||
|
||||
- [ ] import 문 변경 (`prisma` → `query`)
|
||||
- [ ] `loginPwdCheck()` 메서드 전환
|
||||
- [ ] Prisma findUnique → Raw Query SELECT
|
||||
- [ ] 타입 정의 추가
|
||||
- [ ] 에러 처리 확인
|
||||
- [ ] `insertLoginAccessLog()` 메서드 확인
|
||||
- [ ] 이미 Raw Query 사용 중 (유지)
|
||||
- [ ] 파라미터 바인딩 확인
|
||||
- [ ] `getUserInfo()` 메서드 전환
|
||||
- [ ] 사용자 정보 조회 Raw Query 전환
|
||||
- [ ] 권한 정보 조회 Raw Query 전환 (JOIN 최적화)
|
||||
- [ ] 회사 정보 조회 Raw Query 전환
|
||||
- [ ] PersonBean 타입 변환 로직 유지
|
||||
- [ ] 모든 메서드 타입 안전성 확인
|
||||
- [ ] 단위 테스트 작성 및 통과
|
||||
|
||||
### AdminService 확인
|
||||
|
||||
- [ ] 현재 코드 확인 (이미 Raw Query 사용 중)
|
||||
- [ ] WITH RECURSIVE 쿼리 동작 확인
|
||||
- [ ] 다국어 번역 로직 확인
|
||||
|
||||
### AdminController 전환
|
||||
|
||||
- [ ] Prisma 사용 현황 파악 (28개 호출)
|
||||
- [ ] 각 API 엔드포인트별 전환 계획 수립
|
||||
- [ ] Raw Query로 전환
|
||||
- [ ] 통합 테스트 작성
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
- [ ] 로그인 → 토큰 발급 테스트
|
||||
- [ ] 토큰 검증 → API 호출 테스트
|
||||
- [ ] 권한 확인 → 메뉴 조회 테스트
|
||||
- [ ] 로그아웃 테스트
|
||||
- [ ] 에러 케이스 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- ✅ AuthService의 모든 Prisma 호출 제거
|
||||
- ✅ AdminService Raw Query 사용 확인
|
||||
- ✅ AdminController Prisma 호출 제거
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ 통합 테스트 통과
|
||||
- ✅ 로그인 → 인증 → API 호출 플로우 정상 동작
|
||||
- ✅ 성능 저하 없음 (기존 대비 ±10% 이내)
|
||||
- ✅ 에러 처리 및 로깅 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 문서
|
||||
|
||||
- [Phase 1 완료 가이드](backend-node/PHASE1_USAGE_GUIDE.md)
|
||||
- [DatabaseManager 사용법](backend-node/src/database/db.ts)
|
||||
- [QueryBuilder 사용법](backend-node/src/utils/queryBuilder.ts)
|
||||
- [전체 마이그레이션 계획](PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md)
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 2-3일
|
||||
**담당자**: 백엔드 개발팀
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
# 🗂️ Phase 2.2: TableManagementService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
TableManagementService는 **33개의 Prisma 호출**이 있습니다. 대부분(약 26개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 33개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/tableManagementService.ts` |
|
||||
| 파일 크기 | 3,178 라인 |
|
||||
| Prisma 호출 | 33개 ($queryRaw: 26개, ORM: 7개) |
|
||||
| **현재 진행률** | **0/33 (0%)** ⏳ **전환 필요** |
|
||||
| **전환 필요** | **33개 모두 전환 필요** (SQL은 이미 작성되어 있음) |
|
||||
| 복잡도 | 중간 (SQL 작성은 완료, `query()` 함수로 교체만 필요) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.2) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **33개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- 26개 `$queryRaw` → `query()` 또는 `queryOne()`
|
||||
- 7개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||
- 1개 `$transaction` → `transaction()`
|
||||
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (26개)
|
||||
|
||||
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
await prisma.$queryRaw`SELECT ...`;
|
||||
await prisma.$queryRawUnsafe(sqlString, ...params);
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
await query(`SELECT ...`);
|
||||
await query(sqlString, params);
|
||||
```
|
||||
|
||||
### 2. ORM 메서드 사용 (7개)
|
||||
|
||||
**현재 상태**: Prisma ORM 메서드 사용
|
||||
**전환 작업**: SQL 작성 필요
|
||||
|
||||
#### 1. table_labels 관리 (2개)
|
||||
|
||||
```typescript
|
||||
// Line 254: 테이블 라벨 UPSERT
|
||||
await prisma.table_labels.upsert({
|
||||
where: { table_name: tableName },
|
||||
update: {},
|
||||
create: { table_name, table_label, description }
|
||||
});
|
||||
|
||||
// Line 437: 테이블 라벨 조회
|
||||
await prisma.table_labels.findUnique({
|
||||
where: { table_name: tableName },
|
||||
select: { table_name, table_label, description, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. column_labels 관리 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 323: 컬럼 라벨 UPSERT
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName
|
||||
}
|
||||
},
|
||||
update: { column_label, input_type, ... },
|
||||
create: { table_name, column_name, ... }
|
||||
});
|
||||
|
||||
// Line 481: 컬럼 라벨 조회
|
||||
await prisma.column_labels.findUnique({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName
|
||||
}
|
||||
},
|
||||
select: { id, table_name, column_name, ... }
|
||||
});
|
||||
|
||||
// Line 567: 컬럼 존재 확인
|
||||
await prisma.column_labels.findFirst({
|
||||
where: { table_name, column_name }
|
||||
});
|
||||
|
||||
// Line 586: 컬럼 라벨 업데이트
|
||||
await prisma.column_labels.update({
|
||||
where: { id: existingColumn.id },
|
||||
data: { web_type, detail_settings, ... }
|
||||
});
|
||||
|
||||
// Line 610: 컬럼 라벨 생성
|
||||
await prisma.column_labels.create({
|
||||
data: { table_name, column_name, web_type, ... }
|
||||
});
|
||||
|
||||
// Line 1003: 파일 타입 컬럼 조회
|
||||
await prisma.column_labels.findMany({
|
||||
where: { table_name, web_type: 'file' },
|
||||
select: { column_name }
|
||||
});
|
||||
|
||||
// Line 1382: 컬럼 웹타입 정보 조회
|
||||
await prisma.column_labels.findFirst({
|
||||
where: { table_name, column_name },
|
||||
select: { web_type, code_category, ... }
|
||||
});
|
||||
|
||||
// Line 2690: 컬럼 라벨 UPSERT (복제)
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: { table_name, column_name }
|
||||
},
|
||||
update: { column_label, web_type, ... },
|
||||
create: { table_name, column_name, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. attach_file_info 관리 (2개)
|
||||
|
||||
```typescript
|
||||
// Line 914: 파일 정보 조회
|
||||
await prisma.attach_file_info.findMany({
|
||||
where: { target_objid, doc_type, status: 'ACTIVE' },
|
||||
select: { objid, real_file_name, file_size, ... },
|
||||
orderBy: { regdate: 'desc' }
|
||||
});
|
||||
|
||||
// Line 959: 파일 경로로 파일 정보 조회
|
||||
await prisma.attach_file_info.findFirst({
|
||||
where: { file_path, status: 'ACTIVE' },
|
||||
select: { objid, real_file_name, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. 트랜잭션 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 391: 전체 컬럼 설정 일괄 업데이트
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await this.insertTableIfNotExists(tableName);
|
||||
for (const columnSetting of columnSettings) {
|
||||
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: table_labels UPSERT 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.table_labels.upsert({
|
||||
where: { table_name: tableName },
|
||||
update: {},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
table_label: tableName,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (table_name) DO NOTHING`,
|
||||
[tableName, tableName, ""]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: column_labels UPSERT 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
column_label: settings.columnLabel,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
column_label: settings.columnLabel,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
await query(
|
||||
`INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
code_category, code_value, reference_table, reference_column,
|
||||
display_column, display_order, is_visible, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
code_category = EXCLUDED.code_category,
|
||||
code_value = EXCLUDED.code_value,
|
||||
reference_table = EXCLUDED.reference_table,
|
||||
reference_column = EXCLUDED.reference_column,
|
||||
display_column = EXCLUDED.display_column,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = NOW()`,
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
settings.columnLabel,
|
||||
settings.inputType,
|
||||
settings.detailSettings,
|
||||
settings.codeCategory,
|
||||
settings.codeValue,
|
||||
settings.referenceTable,
|
||||
settings.referenceColumn,
|
||||
settings.displayColumn,
|
||||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await this.insertTableIfNotExists(tableName);
|
||||
for (const columnSetting of columnSettings) {
|
||||
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 테이블 라벨 자동 추가
|
||||
await client.query(
|
||||
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (table_name) DO NOTHING`,
|
||||
[tableName, tableName, ""]
|
||||
);
|
||||
|
||||
// 각 컬럼 설정 업데이트
|
||||
for (const columnSetting of columnSettings) {
|
||||
const columnName = columnSetting.columnName;
|
||||
if (columnName) {
|
||||
await client.query(
|
||||
`INSERT INTO column_labels (...)
|
||||
VALUES (...)
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET ...`,
|
||||
[...]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (10개)
|
||||
|
||||
```typescript
|
||||
describe("TableManagementService Raw Query 전환 테스트", () => {
|
||||
describe("insertTableIfNotExists", () => {
|
||||
test("테이블 라벨 UPSERT 성공", async () => { ... });
|
||||
test("중복 테이블 처리", async () => { ... });
|
||||
});
|
||||
|
||||
describe("updateColumnSettings", () => {
|
||||
test("컬럼 설정 UPSERT 성공", async () => { ... });
|
||||
test("기존 컬럼 업데이트", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getTableLabels", () => {
|
||||
test("테이블 라벨 조회 성공", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getColumnLabels", () => {
|
||||
test("컬럼 라벨 조회 성공", async () => { ... });
|
||||
});
|
||||
|
||||
describe("updateAllColumnSettings", () => {
|
||||
test("일괄 업데이트 성공 (트랜잭션)", async () => { ... });
|
||||
test("부분 실패 시 롤백", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getFileInfoByColumnAndTarget", () => {
|
||||
test("파일 정보 조회 성공", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (5개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe("테이블 관리 통합 테스트", () => {
|
||||
test("테이블 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||
test("컬럼 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||
test("컬럼 일괄 설정 업데이트", async () => { ... });
|
||||
test("파일 정보 조회 및 보강", async () => { ... });
|
||||
test("트랜잭션 롤백 테스트", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: table_labels 전환 (2개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `insertTableIfNotExists()` - UPSERT
|
||||
- [ ] `getTableLabels()` - 조회
|
||||
|
||||
### 2단계: column_labels 전환 (5개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `updateColumnSettings()` - UPSERT
|
||||
- [ ] `getColumnLabels()` - 조회
|
||||
- [ ] `updateColumnWebType()` - findFirst + update/create
|
||||
- [ ] `getColumnWebTypeInfo()` - findFirst
|
||||
- [ ] `updateColumnLabel()` - UPSERT (복제)
|
||||
|
||||
### 3단계: attach_file_info 전환 (2개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `getFileInfoByColumnAndTarget()` - findMany
|
||||
- [ ] `getFileInfoByPath()` - findFirst
|
||||
|
||||
### 4단계: 트랜잭션 전환 (1개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `updateAllColumnSettings()` - 트랜잭션
|
||||
|
||||
### 5단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (5개 시나리오)
|
||||
- [ ] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [ ] **33개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] 26개 `$queryRaw` → `query()` 함수로 교체
|
||||
- [ ] 7개 ORM 메서드 → `query()` 함수로 전환 (SQL 작성)
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **트랜잭션 정상 동작 확인**
|
||||
- [ ] **에러 처리 및 롤백 정상 동작**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **모든 통합 테스트 작성 완료 (5개 시나리오)**
|
||||
- [ ] **`import prisma` 완전 제거 및 `import { query, transaction } from "../database/db"` 사용**
|
||||
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)**
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### SQL은 이미 대부분 작성되어 있음
|
||||
|
||||
이 서비스는 이미 79%가 `$queryRaw`를 사용하고 있어, **SQL 작성은 완료**되었습니다:
|
||||
|
||||
- ✅ `information_schema` 조회: SQL 작성 완료 (`$queryRaw` 사용 중)
|
||||
- ✅ 동적 테이블 쿼리: SQL 작성 완료 (`$queryRawUnsafe` 사용 중)
|
||||
- ✅ DDL 실행: SQL 작성 완료 (`$executeRaw` 사용 중)
|
||||
- ⏳ **전환 작업**: `prisma.$queryRaw` → `query()` 함수로 **단순 교체만 필요**
|
||||
- ⏳ CRUD 작업: 7개만 SQL 새로 작성 필요
|
||||
|
||||
### UPSERT 패턴 중요
|
||||
|
||||
대부분의 전환이 UPSERT 패턴이므로 PostgreSQL의 `ON CONFLICT` 구문을 활용합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 1-1.5일 (SQL은 79% 작성 완료, 함수 교체 작업 필요)
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.2)
|
||||
**상태**: ⏳ **진행 예정**
|
||||
**특이사항**: SQL은 대부분 작성되어 있어 `prisma.$queryRaw` → `query()` 단순 교체 작업이 주요 작업
|
||||
|
|
@ -1,736 +0,0 @@
|
|||
# 📊 Phase 2.3: DataflowService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DataflowService는 **31개의 Prisma 호출**이 있는 핵심 서비스입니다. 테이블 간 관계 관리, 데이터플로우 다이어그램, 데이터 연결 브리지 등 복잡한 기능을 포함합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dataflowService.ts` |
|
||||
| 파일 크기 | 1,170+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **31/31 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 매우 높음 (트랜잭션 + 복잡한 관계 관리) |
|
||||
| 우선순위 | 🔴 최우선 (Phase 2.3) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ 31개 Prisma 호출을 모두 Raw Query로 전환
|
||||
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||
- ✅ 에러 처리 및 롤백 정상 동작
|
||||
- ✅ 모든 단위 테스트 통과 (20개 이상)
|
||||
- ✅ 통합 테스트 작성 완료
|
||||
- ✅ Prisma import 완전 제거
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. 테이블 관계 관리 (Table Relationships) - 22개
|
||||
|
||||
#### 1.1 관계 생성 (3개)
|
||||
|
||||
```typescript
|
||||
// Line 48: 최대 diagram_id 조회
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 64: 중복 관계 확인
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { diagram_id, source_table, target_table, relationship_type }
|
||||
});
|
||||
|
||||
// Line 83: 새 관계 생성
|
||||
await prisma.table_relationships.create({
|
||||
data: { diagram_id, source_table, target_table, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.2 관계 조회 (6개)
|
||||
|
||||
```typescript
|
||||
// Line 128: 관계 목록 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
|
||||
// Line 164: 단일 관계 조회
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: whereCondition
|
||||
});
|
||||
|
||||
// Line 287: 회사별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, is_active: 'Y' },
|
||||
orderBy: { diagram_id: 'asc' }
|
||||
});
|
||||
|
||||
// Line 326: 테이블별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { relationship_type: 'asc' }
|
||||
});
|
||||
|
||||
// Line 784: diagram_id별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
select: { diagram_id, diagram_name, source_table, ... }
|
||||
});
|
||||
|
||||
// Line 883: 회사 코드로 전체 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, is_active: 'Y' }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.3 통계 조회 (3개)
|
||||
|
||||
```typescript
|
||||
// Line 362: 전체 관계 수
|
||||
await prisma.table_relationships.count({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
// Line 367: 관계 타입별 통계
|
||||
await prisma.table_relationships.groupBy({
|
||||
by: ["relationship_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
|
||||
// Line 376: 연결 타입별 통계
|
||||
await prisma.table_relationships.groupBy({
|
||||
by: ["connection_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.4 관계 수정/삭제 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 209: 관계 수정
|
||||
await prisma.table_relationships.update({
|
||||
where: { relationship_id },
|
||||
data: { source_table, target_table, ... }
|
||||
});
|
||||
|
||||
// Line 248: 소프트 삭제
|
||||
await prisma.table_relationships.update({
|
||||
where: { relationship_id },
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
|
||||
// Line 936: 중복 diagram_name 확인
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code, diagram_name, is_active: 'Y' }
|
||||
});
|
||||
|
||||
// Line 953: 최대 diagram_id 조회 (복사용)
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 1015: 관계도 완전 삭제
|
||||
await prisma.table_relationships.deleteMany({
|
||||
where: { company_code, diagram_id, is_active: 'Y' }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.5 복잡한 조회 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 919: 원본 관계도 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, diagram_id: sourceDiagramId, is_active: "Y" },
|
||||
});
|
||||
|
||||
// Line 1046: diagram_id로 모든 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { diagram_id, is_active: "Y" },
|
||||
orderBy: { created_at: "asc" },
|
||||
});
|
||||
|
||||
// Line 1085: 특정 relationship_id의 diagram_id 찾기
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { relationship_id, company_code },
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 데이터 연결 브리지 (Data Relationship Bridge) - 8개
|
||||
|
||||
#### 2.1 브리지 생성/수정 (4개)
|
||||
|
||||
```typescript
|
||||
// Line 425: 브리지 생성
|
||||
await prisma.data_relationship_bridge.create({
|
||||
data: {
|
||||
relationship_id,
|
||||
source_record_id,
|
||||
target_record_id,
|
||||
...
|
||||
}
|
||||
});
|
||||
|
||||
// Line 554: 브리지 수정
|
||||
await prisma.data_relationship_bridge.update({
|
||||
where: whereCondition,
|
||||
data: { target_record_id, ... }
|
||||
});
|
||||
|
||||
// Line 595: 브리지 소프트 삭제
|
||||
await prisma.data_relationship_bridge.update({
|
||||
where: whereCondition,
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
|
||||
// Line 637: 브리지 일괄 삭제
|
||||
await prisma.data_relationship_bridge.updateMany({
|
||||
where: whereCondition,
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
```
|
||||
|
||||
#### 2.2 브리지 조회 (4개)
|
||||
|
||||
```typescript
|
||||
// Line 471: relationship_id로 브리지 조회
|
||||
await prisma.data_relationship_bridge.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
|
||||
// Line 512: 레코드별 브리지 조회
|
||||
await prisma.data_relationship_bridge.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Raw Query 사용 (이미 있음) - 1개
|
||||
|
||||
```typescript
|
||||
// Line 673: 테이블 존재 확인
|
||||
await prisma.$queryRaw`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
`;
|
||||
```
|
||||
|
||||
### 4. 트랜잭션 사용 - 1개
|
||||
|
||||
```typescript
|
||||
// Line 968: 관계도 복사 트랜잭션
|
||||
await prisma.$transaction(
|
||||
originalRelationships.map((rel) =>
|
||||
prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: newDiagramId,
|
||||
company_code: companyCode,
|
||||
source_table: rel.source_table,
|
||||
target_table: rel.target_table,
|
||||
...
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 전략
|
||||
|
||||
### 전략 1: 단계적 전환
|
||||
|
||||
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||
2. **2단계**: 복잡한 조회 전환 (groupBy, count, 조건부 조회)
|
||||
3. **3단계**: 트랜잭션 전환
|
||||
4. **4단계**: Raw Query 개선
|
||||
|
||||
### 전략 2: 함수별 전환 우선순위
|
||||
|
||||
#### 🔴 최우선 (기본 CRUD)
|
||||
|
||||
- `createRelationship()` - Line 83
|
||||
- `getRelationships()` - Line 128
|
||||
- `getRelationshipById()` - Line 164
|
||||
- `updateRelationship()` - Line 209
|
||||
- `deleteRelationship()` - Line 248
|
||||
|
||||
#### 🟡 2순위 (브리지 관리)
|
||||
|
||||
- `createDataLink()` - Line 425
|
||||
- `getLinkedData()` - Line 471
|
||||
- `getLinkedDataByRecord()` - Line 512
|
||||
- `updateDataLink()` - Line 554
|
||||
- `deleteDataLink()` - Line 595
|
||||
|
||||
#### 🟢 3순위 (통계 & 조회)
|
||||
|
||||
- `getRelationshipStats()` - Line 362-376
|
||||
- `getAllRelationshipsByCompany()` - Line 287
|
||||
- `getRelationshipsByTable()` - Line 326
|
||||
- `getDiagrams()` - Line 784
|
||||
|
||||
#### 🔵 4순위 (복잡한 기능)
|
||||
|
||||
- `copyDiagram()` - Line 968 (트랜잭션)
|
||||
- `deleteDiagram()` - Line 1015
|
||||
- `getRelationshipsForDiagram()` - Line 1046
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: createRelationship() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 48: 최대 diagram_id 조회
|
||||
const maxDiagramId = await prisma.table_relationships.findFirst({
|
||||
where: { company_code: data.companyCode },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 64: 중복 관계 확인
|
||||
const existingRelationship = await prisma.table_relationships.findFirst({
|
||||
where: {
|
||||
diagram_id: diagramId,
|
||||
source_table: data.sourceTable,
|
||||
target_table: data.targetTable,
|
||||
relationship_type: data.relationshipType
|
||||
}
|
||||
});
|
||||
|
||||
// Line 83: 새 관계 생성
|
||||
const relationship = await prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: diagramId,
|
||||
company_code: data.companyCode,
|
||||
diagram_name: data.diagramName,
|
||||
source_table: data.sourceTable,
|
||||
target_table: data.targetTable,
|
||||
relationship_type: data.relationshipType,
|
||||
...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
// 최대 diagram_id 조회
|
||||
const maxDiagramResult = await query<{ diagram_id: number }>(
|
||||
`SELECT diagram_id FROM table_relationships
|
||||
WHERE company_code = $1
|
||||
ORDER BY diagram_id DESC
|
||||
LIMIT 1`,
|
||||
[data.companyCode]
|
||||
);
|
||||
|
||||
const diagramId =
|
||||
data.diagramId ||
|
||||
(maxDiagramResult.length > 0 ? maxDiagramResult[0].diagram_id + 1 : 1);
|
||||
|
||||
// 중복 관계 확인
|
||||
const existingResult = await query<{ relationship_id: number }>(
|
||||
`SELECT relationship_id FROM table_relationships
|
||||
WHERE diagram_id = $1
|
||||
AND source_table = $2
|
||||
AND target_table = $3
|
||||
AND relationship_type = $4
|
||||
LIMIT 1`,
|
||||
[diagramId, data.sourceTable, data.targetTable, data.relationshipType]
|
||||
);
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
throw new Error("이미 존재하는 관계입니다.");
|
||||
}
|
||||
|
||||
// 새 관계 생성
|
||||
const [relationship] = await query<TableRelationship>(
|
||||
`INSERT INTO table_relationships (
|
||||
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||
relationship_type, connection_type, source_column, target_column,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
diagramId,
|
||||
data.companyCode,
|
||||
data.diagramName,
|
||||
data.sourceTable,
|
||||
data.targetTable,
|
||||
data.relationshipType,
|
||||
data.connectionType,
|
||||
data.sourceColumn,
|
||||
data.targetColumn,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: getRelationshipStats() 전환 (통계 조회)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 362: 전체 관계 수
|
||||
const totalCount = await prisma.table_relationships.count({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
// Line 367: 관계 타입별 통계
|
||||
const relationshipTypeStats = await prisma.table_relationships.groupBy({
|
||||
by: ["relationship_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
|
||||
// Line 376: 연결 타입별 통계
|
||||
const connectionTypeStats = await prisma.table_relationships.groupBy({
|
||||
by: ["connection_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
// WHERE 조건 동적 생성
|
||||
const whereParams: any[] = [];
|
||||
let whereSQL = "";
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereSQL += `WHERE company_code = $${paramIndex}`;
|
||||
whereParams.push(companyCode);
|
||||
paramIndex++;
|
||||
|
||||
if (isActive !== undefined) {
|
||||
whereSQL += ` AND is_active = $${paramIndex}`;
|
||||
whereParams.push(isActive ? "Y" : "N");
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 관계 수
|
||||
const [totalResult] = await query<{ count: number }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}`,
|
||||
whereParams
|
||||
);
|
||||
|
||||
const totalCount = totalResult?.count || 0;
|
||||
|
||||
// 관계 타입별 통계
|
||||
const relationshipTypeStats = await query<{
|
||||
relationship_type: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT relationship_type, COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}
|
||||
GROUP BY relationship_type
|
||||
ORDER BY count DESC`,
|
||||
whereParams
|
||||
);
|
||||
|
||||
// 연결 타입별 통계
|
||||
const connectionTypeStats = await query<{
|
||||
connection_type: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT connection_type, COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}
|
||||
GROUP BY connection_type
|
||||
ORDER BY count DESC`,
|
||||
whereParams
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: copyDiagram() 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 968: 트랜잭션으로 모든 관계 복사
|
||||
const copiedRelationships = await prisma.$transaction(
|
||||
originalRelationships.map((rel) =>
|
||||
prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: newDiagramId,
|
||||
company_code: companyCode,
|
||||
diagram_name: newDiagramName,
|
||||
source_table: rel.source_table,
|
||||
target_table: rel.target_table,
|
||||
...
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
const copiedRelationships = await transaction(async (client) => {
|
||||
const results: TableRelationship[] = [];
|
||||
|
||||
for (const rel of originalRelationships) {
|
||||
const [copiedRel] = await client.query<TableRelationship>(
|
||||
`INSERT INTO table_relationships (
|
||||
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||
relationship_type, connection_type, source_column, target_column,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
newDiagramId,
|
||||
companyCode,
|
||||
newDiagramName,
|
||||
rel.source_table,
|
||||
rel.target_table,
|
||||
rel.relationship_type,
|
||||
rel.connection_type,
|
||||
rel.source_column,
|
||||
rel.target_column,
|
||||
]
|
||||
);
|
||||
|
||||
results.push(copiedRel);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (20개 이상)
|
||||
|
||||
```typescript
|
||||
describe('DataflowService Raw Query 전환 테스트', () => {
|
||||
describe('createRelationship', () => {
|
||||
test('관계 생성 성공', async () => { ... });
|
||||
test('중복 관계 에러', async () => { ... });
|
||||
test('diagram_id 자동 생성', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getRelationships', () => {
|
||||
test('전체 관계 조회 성공', async () => { ... });
|
||||
test('회사별 필터링', async () => { ... });
|
||||
test('diagram_id별 필터링', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getRelationshipStats', () => {
|
||||
test('통계 조회 성공', async () => { ... });
|
||||
test('관계 타입별 그룹화', async () => { ... });
|
||||
test('연결 타입별 그룹화', async () => { ... });
|
||||
});
|
||||
|
||||
describe('copyDiagram', () => {
|
||||
test('관계도 복사 성공 (트랜잭션)', async () => { ... });
|
||||
test('diagram_name 중복 에러', async () => { ... });
|
||||
});
|
||||
|
||||
describe('createDataLink', () => {
|
||||
test('데이터 연결 생성 성공', async () => { ... });
|
||||
test('브리지 레코드 저장', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getLinkedData', () => {
|
||||
test('연결된 데이터 조회', async () => { ... });
|
||||
test('relationship_id별 필터링', async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (7개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe('Dataflow 관리 통합 테스트', () => {
|
||||
test('관계 생명주기 (생성 → 조회 → 수정 → 삭제)', async () => { ... });
|
||||
test('관계도 복사 및 검증', async () => { ... });
|
||||
test('데이터 연결 브리지 생성 및 조회', async () => { ... });
|
||||
test('통계 정보 조회', async () => { ... });
|
||||
test('테이블별 관계 조회', async () => { ... });
|
||||
test('diagram_id별 관계 조회', async () => { ... });
|
||||
test('관계도 완전 삭제', async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createTableRelationship()` - 관계 생성
|
||||
- [x] `getTableRelationships()` - 관계 목록 조회
|
||||
- [x] `getTableRelationship()` - 단일 관계 조회
|
||||
- [x] `updateTableRelationship()` - 관계 수정
|
||||
- [x] `deleteTableRelationship()` - 관계 삭제 (소프트)
|
||||
- [x] `getRelationshipsByTable()` - 테이블별 조회
|
||||
- [x] `getRelationshipsByConnectionType()` - 연결타입별 조회
|
||||
- [x] `getDataFlowDiagrams()` - diagram_id별 그룹 조회
|
||||
|
||||
### 2단계: 브리지 관리 (6개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createDataLink()` - 데이터 연결 생성
|
||||
- [x] `getLinkedDataByRelationship()` - 관계별 연결 데이터 조회
|
||||
- [x] `getLinkedDataByTable()` - 테이블별 연결 데이터 조회
|
||||
- [x] `updateDataLink()` - 연결 수정
|
||||
- [x] `deleteDataLink()` - 연결 삭제 (소프트)
|
||||
- [x] `deleteAllLinkedDataByRelationship()` - 관계별 모든 연결 삭제
|
||||
|
||||
### 3단계: 통계 & 복잡한 조회 (4개 함수) ✅ **완료**
|
||||
|
||||
- [x] `getRelationshipStats()` - 통계 조회
|
||||
- [x] count 쿼리 전환
|
||||
- [x] groupBy 쿼리 전환 (관계 타입별)
|
||||
- [x] groupBy 쿼리 전환 (연결 타입별)
|
||||
- [x] `getTableData()` - 테이블 데이터 조회 (페이징)
|
||||
- [x] `getDiagramRelationships()` - 관계도 관계 조회
|
||||
- [x] `getDiagramRelationshipsByDiagramId()` - diagram_id별 관계 조회
|
||||
|
||||
### 4단계: 복잡한 기능 (3개 함수) ✅ **완료**
|
||||
|
||||
- [x] `copyDiagram()` - 관계도 복사 (트랜잭션)
|
||||
- [x] `deleteDiagram()` - 관계도 완전 삭제
|
||||
- [x] `getDiagramRelationshipsByRelationshipId()` - relationship_id로 조회
|
||||
|
||||
### 5단계: 테스트 & 검증 ⏳ **진행 필요**
|
||||
|
||||
- [ ] 단위 테스트 작성 (20개 이상)
|
||||
- createTableRelationship, updateTableRelationship, deleteTableRelationship
|
||||
- getTableRelationships, getTableRelationship
|
||||
- createDataLink, getLinkedDataByRelationship
|
||||
- getRelationshipStats
|
||||
- copyDiagram
|
||||
- [ ] 통합 테스트 작성 (7개 시나리오)
|
||||
- 관계 생명주기 테스트
|
||||
- 관계도 복사 테스트
|
||||
- 데이터 브리지 테스트
|
||||
- 통계 조회 테스트
|
||||
- [x] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **31개 Prisma 호출 모두 Raw Query로 전환 완료** ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **트랜잭션 정상 동작 확인** ✅
|
||||
- [x] **에러 처리 및 롤백 정상 동작** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (20개 이상)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (7개 시나리오)** ⏳
|
||||
- [x] **Prisma import 완전 제거** ✅
|
||||
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)** ⏳
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기술적 도전 과제
|
||||
|
||||
### 1. groupBy 쿼리 전환
|
||||
|
||||
**문제**: Prisma의 `groupBy`를 Raw Query로 전환
|
||||
**해결**: PostgreSQL의 `GROUP BY` 및 집계 함수 사용
|
||||
|
||||
```sql
|
||||
SELECT relationship_type, COUNT(*) as count
|
||||
FROM table_relationships
|
||||
WHERE company_code = $1 AND is_active = 'Y'
|
||||
GROUP BY relationship_type
|
||||
ORDER BY count DESC
|
||||
```
|
||||
|
||||
### 2. 트랜잭션 배열 처리
|
||||
|
||||
**문제**: Prisma의 `$transaction([...])` 배열 방식을 Raw Query로 전환
|
||||
**해결**: `transaction` 함수 내에서 순차 실행
|
||||
|
||||
```typescript
|
||||
await transaction(async (client) => {
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
const result = await client.query(...);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 동적 WHERE 조건 생성
|
||||
|
||||
**문제**: 다양한 필터 조건을 동적으로 구성
|
||||
**해결**: 조건부 파라미터 인덱스 관리
|
||||
|
||||
```typescript
|
||||
const whereParams: any[] = [];
|
||||
const whereConditions: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
whereParams.push(companyCode);
|
||||
}
|
||||
|
||||
if (diagramId) {
|
||||
whereConditions.push(`diagram_id = $${paramIndex++}`);
|
||||
whereParams.push(diagramId);
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 전환 완료 요약
|
||||
|
||||
### ✅ 성공적으로 전환된 항목
|
||||
|
||||
1. **기본 CRUD (8개)**: 모든 테이블 관계 CRUD 작업을 Raw Query로 전환
|
||||
2. **브리지 관리 (6개)**: 데이터 연결 브리지의 모든 작업 전환
|
||||
3. **통계 & 조회 (4개)**: COUNT, GROUP BY 등 복잡한 통계 쿼리 전환
|
||||
4. **복잡한 기능 (3개)**: 트랜잭션 기반 관계도 복사 등 고급 기능 전환
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **트랜잭션 처리**: `transaction()` 함수 내에서 `client.query().rows` 사용
|
||||
2. **동적 WHERE 조건**: 파라미터 인덱스를 동적으로 관리하여 유연한 쿼리 생성
|
||||
3. **GROUP BY 전환**: Prisma의 `groupBy`를 PostgreSQL의 네이티브 GROUP BY로 전환
|
||||
4. **타입 안전성**: 모든 쿼리 결과에 TypeScript 타입 지정
|
||||
|
||||
### 📈 다음 단계
|
||||
|
||||
- [ ] 단위 테스트 작성 및 실행
|
||||
- [ ] 통합 테스트 시나리오 구현
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
- [ ] 프로덕션 배포 준비
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 1일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🔴 최우선 (Phase 2.3)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
# 📝 Phase 2.4: DynamicFormService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DynamicFormService는 **13개의 Prisma 호출**이 있습니다. 대부분(약 11개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 13개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dynamicFormService.ts` |
|
||||
| 파일 크기 | 1,213 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **13/13 (100%)** ✅ **완료** |
|
||||
| **전환 상태** | **Raw Query로 전환 완료** |
|
||||
| 복잡도 | 낮음 (SQL 작성 완료 → `query()` 함수로 교체 완료) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 2.4) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **13개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- 11개 `$queryRaw` → `query()` 함수로 교체
|
||||
- 2개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (11개)
|
||||
|
||||
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
await prisma.$queryRaw<Array<{ column_name; data_type }>>`...`;
|
||||
await prisma.$queryRawUnsafe(upsertQuery, ...values);
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
await query<Array<{ column_name: string; data_type: string }>>(`...`);
|
||||
await query(upsertQuery, values);
|
||||
```
|
||||
|
||||
### 2. ORM 메서드 사용 (2개)
|
||||
|
||||
**현재 상태**: Prisma ORM 메서드 사용
|
||||
**전환 작업**: SQL 작성 필요
|
||||
|
||||
#### 1. dynamic_form_data 조회 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 867: 폼 데이터 조회
|
||||
const result = await prisma.dynamic_form_data.findUnique({
|
||||
where: { id },
|
||||
select: { data: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. screen_layouts 조회 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 1101: 화면 레이아웃 조회
|
||||
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||
where: {
|
||||
screen_id: screenId,
|
||||
component_type: "widget",
|
||||
},
|
||||
select: {
|
||||
component_id: true,
|
||||
properties: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: dynamic_form_data 조회 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const result = await prisma.dynamic_form_data.findUnique({
|
||||
where: { id },
|
||||
select: { data: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { queryOne } from "../database/db";
|
||||
|
||||
const result = await queryOne<{ data: any }>(
|
||||
`SELECT data FROM dynamic_form_data WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: screen_layouts 조회 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||
where: {
|
||||
screen_id: screenId,
|
||||
component_type: "widget",
|
||||
},
|
||||
select: {
|
||||
component_id: true,
|
||||
properties: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
const screenLayouts = await query<{
|
||||
component_id: string;
|
||||
properties: any;
|
||||
}>(
|
||||
`SELECT component_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1 AND component_type = $2`,
|
||||
[screenId, "widget"]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (5개)
|
||||
|
||||
```typescript
|
||||
describe("DynamicFormService Raw Query 전환 테스트", () => {
|
||||
describe("getFormDataById", () => {
|
||||
test("폼 데이터 조회 성공", async () => { ... });
|
||||
test("존재하지 않는 데이터", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getScreenLayoutsForControl", () => {
|
||||
test("화면 레이아웃 조회 성공", async () => { ... });
|
||||
test("widget 타입만 필터링", async () => { ... });
|
||||
test("빈 결과 처리", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (3개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe("동적 폼 통합 테스트", () => {
|
||||
test("폼 데이터 UPSERT → 조회", async () => { ... });
|
||||
test("폼 데이터 업데이트 → 조회", async () => { ... });
|
||||
test("화면 레이아웃 조회 → 제어 설정 확인", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (13개 Raw Query 호출)
|
||||
|
||||
1. **getTableColumnInfo()** - 컬럼 정보 조회
|
||||
2. **getPrimaryKeyColumns()** - 기본 키 조회
|
||||
3. **getNotNullColumns()** - NOT NULL 컬럼 조회
|
||||
4. **upsertFormData()** - UPSERT 실행
|
||||
5. **partialUpdateFormData()** - 부분 업데이트
|
||||
6. **updateFormData()** - 전체 업데이트
|
||||
7. **deleteFormData()** - 데이터 삭제
|
||||
8. **getFormDataById()** - 폼 데이터 조회
|
||||
9. **getTableColumns()** - 테이블 컬럼 조회
|
||||
10. **getTablePrimaryKeys()** - 기본 키 조회
|
||||
11. **getScreenLayoutsForControl()** - 화면 레이아웃 조회
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||
2. **동적 UPSERT 쿼리**: PostgreSQL ON CONFLICT 구문 사용
|
||||
3. **부분 업데이트**: 동적 SET 절 생성
|
||||
4. **타입 변환**: PostgreSQL 타입 자동 변환 로직 유지
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: ORM 호출 전환 ✅ **완료**
|
||||
|
||||
- [x] `getFormDataById()` - queryOne 전환
|
||||
- [x] `getScreenLayoutsForControl()` - query 전환
|
||||
- [x] 모든 Raw Query 함수 전환
|
||||
|
||||
### 2단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 통합 테스트 작성 (3개 시나리오)
|
||||
- [x] Prisma import 완전 제거 확인 ✅
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **13개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||
- [x] 11개 `$queryRaw` → `query()` 함수로 교체 ✅
|
||||
- [x] 2개 ORM 메서드 → `query()` 함수로 전환 ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **`import prisma` 완전 제거** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (5개)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)** ⏳
|
||||
- [ ] **성능 저하 없음** ⏳
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 완료됨 (이전에 전환)
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 2.4)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
**특이사항**: SQL은 이미 작성되어 있었고, `query()` 함수로 교체 완료
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
# 🔌 Phase 2.5: ExternalDbConnectionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ExternalDbConnectionService는 **15개의 Prisma 호출**이 있으며, 외부 데이터베이스 연결 정보를 관리하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/externalDbConnectionService.ts` |
|
||||
| 파일 크기 | 1,100+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **15/15 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (CRUD + 연결 테스트) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.5) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ 15개 Prisma 호출을 모두 Raw Query로 전환
|
||||
- ✅ 민감 정보 암호화 처리 유지
|
||||
- ✅ 연결 테스트 로직 정상 동작
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
|
||||
---
|
||||
|
||||
## 🔍 주요 기능
|
||||
|
||||
### 1. 외부 DB 연결 정보 CRUD
|
||||
|
||||
- 생성, 조회, 수정, 삭제
|
||||
- 연결 정보 암호화/복호화
|
||||
|
||||
### 2. 연결 테스트
|
||||
|
||||
- MySQL, PostgreSQL, MSSQL, Oracle 연결 테스트
|
||||
|
||||
### 3. 연결 정보 관리
|
||||
|
||||
- 회사별 연결 정보 조회
|
||||
- 활성/비활성 상태 관리
|
||||
|
||||
---
|
||||
|
||||
## 📝 예상 전환 패턴
|
||||
|
||||
### CRUD 작업
|
||||
|
||||
```typescript
|
||||
// 생성
|
||||
await query(
|
||||
`INSERT INTO external_db_connections
|
||||
(connection_name, db_type, host, port, database_name, username, password, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 조회
|
||||
await query(
|
||||
`SELECT * FROM external_db_connections
|
||||
WHERE company_code = $1 AND is_active = 'Y'`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
// 수정
|
||||
await query(
|
||||
`UPDATE external_db_connections
|
||||
SET connection_name = $1, host = $2, ...
|
||||
WHERE connection_id = $2`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 삭제 (소프트)
|
||||
await query(
|
||||
`UPDATE external_db_connections
|
||||
SET is_active = 'N'
|
||||
WHERE connection_id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (15개 Prisma 호출)
|
||||
|
||||
1. **getConnections()** - 동적 WHERE 조건 생성으로 전환
|
||||
2. **getConnectionsGroupedByType()** - DB 타입 카테고리 조회
|
||||
3. **getConnectionById()** - 단일 연결 조회 (비밀번호 마스킹)
|
||||
4. **getConnectionByIdWithPassword()** - 비밀번호 포함 조회
|
||||
5. **createConnection()** - 새 연결 생성 + 중복 확인
|
||||
6. **updateConnection()** - 동적 필드 업데이트
|
||||
7. **deleteConnection()** - 물리 삭제
|
||||
8. **testConnectionById()** - 연결 테스트용 조회
|
||||
9. **getDecryptedPassword()** - 비밀번호 복호화용 조회
|
||||
10. **executeQuery()** - 쿼리 실행용 조회
|
||||
11. **getTables()** - 테이블 목록 조회용
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **동적 WHERE 조건 생성**: 필터 조건에 따라 동적으로 SQL 생성
|
||||
2. **동적 UPDATE 쿼리**: 변경된 필드만 업데이트하도록 구현
|
||||
3. **ILIKE 검색**: 대소문자 구분 없는 검색 지원
|
||||
4. **암호화 로직 유지**: PasswordEncryption 클래스와 통합 유지
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **15개 Prisma 호출 모두 Raw Query로 전환** ✅
|
||||
- [x] **암호화/복호화 로직 정상 동작** ✅
|
||||
- [x] **연결 테스트 정상 동작** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (10개 이상)** ⏳
|
||||
- [x] **Prisma import 완전 제거** ✅
|
||||
- [x] **TypeScript 컴파일 성공** ✅
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.5)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
# 🎮 Phase 2.6: DataflowControlService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DataflowControlService는 **6개의 Prisma 호출**이 있으며, 데이터플로우 제어 및 실행을 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dataflowControlService.ts` |
|
||||
| 파일 크기 | 1,100+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 높음 (복잡한 비즈니스 로직) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.6) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **6개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- ✅ 복잡한 비즈니스 로직 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **데이터플로우 실행 관리**
|
||||
- 관계 기반 데이터 조회 및 저장
|
||||
- 조건부 실행 로직
|
||||
2. **트랜잭션 처리**
|
||||
- 여러 테이블에 걸친 데이터 처리
|
||||
3. **데이터 변환 및 매핑**
|
||||
- 소스-타겟 데이터 변환
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 조회 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getRelationshipById()` - 관계 정보 조회
|
||||
- `getDataflowConfig()` - 데이터플로우 설정 조회
|
||||
|
||||
### 2단계: 데이터 실행 로직 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeDataflow()` - 데이터플로우 실행
|
||||
- `validateDataflow()` - 데이터플로우 검증
|
||||
|
||||
### 3단계: 복잡한 기능 - 트랜잭션 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeWithTransaction()` - 트랜잭션 내 실행
|
||||
- `rollbackOnError()` - 에러 시 롤백
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 관계 정보 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const relationship = await prisma.table_relationship.findUnique({
|
||||
where: { relationship_id: relationshipId },
|
||||
include: {
|
||||
source_table: true,
|
||||
target_table: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const relationship = await query<TableRelationship>(
|
||||
`SELECT
|
||||
tr.*,
|
||||
st.table_name as source_table_name,
|
||||
tt.table_name as target_table_name
|
||||
FROM table_relationship tr
|
||||
LEFT JOIN table_labels st ON tr.source_table_id = st.table_id
|
||||
LEFT JOIN table_labels tt ON tr.target_table_id = tt.table_id
|
||||
WHERE tr.relationship_id = $1`,
|
||||
[relationshipId]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 트랜잭션 내 실행
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 소스 데이터 조회
|
||||
const sourceData = await tx.dynamic_form_data.findMany(...);
|
||||
|
||||
// 타겟 데이터 저장
|
||||
await tx.dynamic_form_data.createMany(...);
|
||||
|
||||
// 실행 로그 저장
|
||||
await tx.dataflow_execution_log.create(...);
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 소스 데이터 조회
|
||||
const sourceData = await client.query(
|
||||
`SELECT * FROM dynamic_form_data WHERE ...`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 타겟 데이터 저장
|
||||
await client.query(
|
||||
`INSERT INTO dynamic_form_data (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 실행 로그 저장
|
||||
await client.query(
|
||||
`INSERT INTO dataflow_execution_log (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 5단계: 테스트 & 검증
|
||||
|
||||
### 단위 테스트 (10개)
|
||||
|
||||
- [ ] getRelationshipById - 관계 정보 조회
|
||||
- [ ] getDataflowConfig - 설정 조회
|
||||
- [ ] executeDataflow - 데이터플로우 실행
|
||||
- [ ] validateDataflow - 검증
|
||||
- [ ] executeWithTransaction - 트랜잭션 실행
|
||||
- [ ] rollbackOnError - 에러 처리
|
||||
- [ ] transformData - 데이터 변환
|
||||
- [ ] mapSourceToTarget - 필드 매핑
|
||||
- [ ] applyConditions - 조건 적용
|
||||
- [ ] logExecution - 실행 로그
|
||||
|
||||
### 통합 테스트 (4개 시나리오)
|
||||
|
||||
1. **데이터플로우 실행 시나리오**
|
||||
- 관계 조회 → 데이터 실행 → 로그 저장
|
||||
2. **트랜잭션 테스트**
|
||||
- 여러 테이블 동시 처리
|
||||
- 에러 발생 시 롤백
|
||||
3. **조건부 실행 테스트**
|
||||
- 조건에 따른 데이터 처리
|
||||
4. **데이터 변환 테스트**
|
||||
- 소스-타겟 데이터 매핑
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (6개 Prisma 호출)
|
||||
|
||||
1. **executeDataflowControl()** - 관계도 정보 조회 (findUnique → queryOne)
|
||||
2. **evaluateActionConditions()** - 대상 테이블 조건 확인 ($queryRawUnsafe → query)
|
||||
3. **executeInsertAction()** - INSERT 실행 ($executeRawUnsafe → query)
|
||||
4. **executeUpdateAction()** - UPDATE 실행 ($executeRawUnsafe → query)
|
||||
5. **executeDeleteAction()** - DELETE 실행 ($executeRawUnsafe → query)
|
||||
6. **checkColumnExists()** - 컬럼 존재 확인 ($queryRawUnsafe → query)
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||
2. **동적 테이블 쿼리 전환**: `$queryRawUnsafe` / `$executeRawUnsafe` → `query()`
|
||||
3. **파라미터 바인딩 수정**: MySQL `?` → PostgreSQL `$1, $2...`
|
||||
4. **복잡한 비즈니스 로직 유지**: 조건부 실행, 다중 커넥션, 에러 처리
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **6개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **`import prisma` 완전 제거** ✅
|
||||
- [ ] **트랜잭션 정상 동작 확인** ⏳
|
||||
- [ ] **복잡한 비즈니스 로직 정상 동작** ⏳
|
||||
- [ ] **모든 단위 테스트 통과 (10개)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (4개 시나리오)** ⏳
|
||||
- [ ] **성능 저하 없음** ⏳
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### 복잡한 비즈니스 로직
|
||||
|
||||
이 서비스는 데이터플로우 제어라는 복잡한 비즈니스 로직을 처리합니다:
|
||||
|
||||
- 조건부 실행 로직
|
||||
- 데이터 변환 및 매핑
|
||||
- 트랜잭션 관리
|
||||
- 에러 처리 및 롤백
|
||||
|
||||
### 성능 최적화 중요
|
||||
|
||||
데이터플로우 실행은 대량의 데이터를 처리할 수 있으므로:
|
||||
|
||||
- 배치 처리 고려
|
||||
- 인덱스 활용
|
||||
- 쿼리 최적화
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 30분
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.6)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# 🔧 Phase 2.7: DDLExecutionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definition Language) 실행 및 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||
| 파일 크기 | 400+ 라인 |
|
||||
| Prisma 호출 | 4개 |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (DDL 실행 + 로그 관리) |
|
||||
| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **4개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- ✅ DDL 실행 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **DDL 실행**
|
||||
- CREATE TABLE, ALTER TABLE, DROP TABLE
|
||||
- CREATE INDEX, DROP INDEX
|
||||
2. **실행 로그 관리**
|
||||
- DDL 실행 이력 저장
|
||||
- 에러 로그 관리
|
||||
3. **롤백 지원**
|
||||
- DDL 롤백 SQL 생성 및 실행
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: DDL 실행 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeDDL()` - DDL 실행
|
||||
- `validateDDL()` - DDL 문법 검증
|
||||
|
||||
### 2단계: 로그 관리 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `saveDDLLog()` - 실행 로그 저장
|
||||
- `getDDLHistory()` - 실행 이력 조회
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: DDL 실행 및 로그 저장
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.$executeRawUnsafe(ddlQuery);
|
||||
|
||||
await prisma.ddl_execution_log.create({
|
||||
data: {
|
||||
ddl_statement: ddlQuery,
|
||||
execution_status: "SUCCESS",
|
||||
executed_by: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(ddlQuery);
|
||||
|
||||
await query(
|
||||
`INSERT INTO ddl_execution_log
|
||||
(ddl_statement, execution_status, executed_by, executed_date)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[ddlQuery, "SUCCESS", userId, new Date()]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: DDL 실행 이력 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const history = await prisma.ddl_execution_log.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
execution_status: "SUCCESS",
|
||||
},
|
||||
orderBy: { executed_date: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const history = await query<DDLLog[]>(
|
||||
`SELECT * FROM ddl_execution_log
|
||||
WHERE company_code = $1
|
||||
AND execution_status = $2
|
||||
ORDER BY executed_date DESC
|
||||
LIMIT $3`,
|
||||
[companyCode, "SUCCESS", 50]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 3단계: 테스트 & 검증
|
||||
|
||||
### 단위 테스트 (8개)
|
||||
|
||||
- [ ] executeDDL - CREATE TABLE
|
||||
- [ ] executeDDL - ALTER TABLE
|
||||
- [ ] executeDDL - DROP TABLE
|
||||
- [ ] executeDDL - CREATE INDEX
|
||||
- [ ] validateDDL - 문법 검증
|
||||
- [ ] saveDDLLog - 로그 저장
|
||||
- [ ] getDDLHistory - 이력 조회
|
||||
- [ ] rollbackDDL - DDL 롤백
|
||||
|
||||
### 통합 테스트 (3개 시나리오)
|
||||
|
||||
1. **테이블 생성 → 로그 저장 → 이력 조회**
|
||||
2. **DDL 실행 실패 → 에러 로그 저장**
|
||||
3. **DDL 롤백 테스트**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [ ] **4개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **DDL 실행 정상 동작 확인**
|
||||
- [ ] **모든 단위 테스트 통과 (8개)**
|
||||
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)**
|
||||
- [ ] **`import prisma` 완전 제거 및 `import { query } from "../database/db"` 사용**
|
||||
- [ ] **성능 저하 없음**
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### DDL 실행의 위험성
|
||||
|
||||
DDL은 데이터베이스 스키마를 변경하므로 매우 신중하게 처리해야 합니다:
|
||||
|
||||
- 실행 전 검증 필수
|
||||
- 롤백 SQL 자동 생성
|
||||
- 실행 이력 철저히 관리
|
||||
|
||||
### 트랜잭션 지원 제한
|
||||
|
||||
PostgreSQL에서 일부 DDL은 트랜잭션을 지원하지만, 일부는 자동 커밋됩니다:
|
||||
|
||||
- CREATE TABLE: 트랜잭션 지원 ✅
|
||||
- DROP TABLE: 트랜잭션 지원 ✅
|
||||
- CREATE INDEX CONCURRENTLY: 트랜잭션 미지원 ❌
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 0.5일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 2.7)
|
||||
**상태**: ⏳ **진행 예정**
|
||||
**특이사항**: DDL 실행의 특성상 신중한 테스트 필요
|
||||
|
|
@ -1,566 +0,0 @@
|
|||
# 🖥️ Phase 2.1: ScreenManagementService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ScreenManagementService는 **46개의 Prisma 호출**이 있는 가장 복잡한 서비스입니다. 화면 정의, 레이아웃, 메뉴 할당, 템플릿 등 다양한 기능을 포함합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/screenManagementService.ts` |
|
||||
| 파일 크기 | 1,700+ 라인 |
|
||||
| Prisma 호출 | 46개 |
|
||||
| **현재 진행률** | **46/46 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 매우 높음 |
|
||||
| 우선순위 | 🔴 최우선 |
|
||||
|
||||
### 🎯 전환 현황 (2025-09-30 업데이트)
|
||||
|
||||
- ✅ **Stage 1 완료**: 기본 CRUD (8개 함수) - Commit: 13c1bc4, 0e8d1d4
|
||||
- ✅ **Stage 2 완료**: 레이아웃 관리 (2개 함수, 4 Prisma 호출) - Commit: 67dced7
|
||||
- ✅ **Stage 3 완료**: 템플릿 & 메뉴 관리 (5개 함수) - Commit: 74351e8
|
||||
- ✅ **Stage 4 완료**: 복잡한 기능 (트랜잭션) - **모든 46개 Prisma 호출 전환 완료**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. 화면 정의 관리 (Screen Definitions) - 18개
|
||||
|
||||
```typescript
|
||||
// Line 53: 화면 코드 중복 확인
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_code, is_active: { not: "D" } } })
|
||||
|
||||
// Line 70: 화면 생성
|
||||
await prisma.screen_definitions.create({ data: { ... } })
|
||||
|
||||
// Line 99: 화면 목록 조회 (페이징)
|
||||
await prisma.screen_definitions.findMany({ where, skip, take, orderBy })
|
||||
|
||||
// Line 105: 화면 총 개수
|
||||
await prisma.screen_definitions.count({ where })
|
||||
|
||||
// Line 166: 전체 화면 목록
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 178: 화면 코드로 조회
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_code } })
|
||||
|
||||
// Line 205: 화면 ID로 조회
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_id } })
|
||||
|
||||
// Line 221: 화면 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where: { screen_id } })
|
||||
|
||||
// Line 236: 화면 업데이트
|
||||
await prisma.screen_definitions.update({ where, data })
|
||||
|
||||
// Line 268: 화면 복사 - 원본 조회
|
||||
await prisma.screen_definitions.findUnique({ where, include: { screen_layouts } })
|
||||
|
||||
// Line 292: 화면 순서 변경 - 전체 조회
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 486: 화면 템플릿 적용 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 557: 화면 복사 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 578: 화면 복사 - 중복 확인
|
||||
await prisma.screen_definitions.findFirst({ where })
|
||||
|
||||
// Line 651: 화면 삭제 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 672: 화면 삭제 (물리 삭제)
|
||||
await prisma.screen_definitions.delete({ where })
|
||||
|
||||
// Line 700: 삭제된 화면 조회
|
||||
await prisma.screen_definitions.findMany({ where: { is_active: "D" } })
|
||||
|
||||
// Line 706: 삭제된 화면 개수
|
||||
await prisma.screen_definitions.count({ where })
|
||||
|
||||
// Line 763: 일괄 삭제 - 화면 조회
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 1083: 레이아웃 저장 - 화면 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 1181: 레이아웃 조회 - 화면 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 1655: 위젯 데이터 저장 - 화면 존재 확인
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
```
|
||||
|
||||
### 2. 레이아웃 관리 (Screen Layouts) - 4개
|
||||
|
||||
```typescript
|
||||
// Line 1096: 레이아웃 삭제
|
||||
await prisma.screen_layouts.deleteMany({ where: { screen_id } });
|
||||
|
||||
// Line 1107: 레이아웃 생성 (단일)
|
||||
await prisma.screen_layouts.create({ data });
|
||||
|
||||
// Line 1152: 레이아웃 생성 (다중)
|
||||
await prisma.screen_layouts.create({ data });
|
||||
|
||||
// Line 1193: 레이아웃 조회
|
||||
await prisma.screen_layouts.findMany({ where });
|
||||
```
|
||||
|
||||
### 3. 템플릿 관리 (Screen Templates) - 2개
|
||||
|
||||
```typescript
|
||||
// Line 1303: 템플릿 목록 조회
|
||||
await prisma.screen_templates.findMany({ where });
|
||||
|
||||
// Line 1317: 템플릿 생성
|
||||
await prisma.screen_templates.create({ data });
|
||||
```
|
||||
|
||||
### 4. 메뉴 할당 (Screen Menu Assignments) - 5개
|
||||
|
||||
```typescript
|
||||
// Line 446: 메뉴 할당 조회
|
||||
await prisma.screen_menu_assignments.findMany({ where });
|
||||
|
||||
// Line 1346: 메뉴 할당 중복 확인
|
||||
await prisma.screen_menu_assignments.findFirst({ where });
|
||||
|
||||
// Line 1358: 메뉴 할당 생성
|
||||
await prisma.screen_menu_assignments.create({ data });
|
||||
|
||||
// Line 1376: 화면별 메뉴 할당 조회
|
||||
await prisma.screen_menu_assignments.findMany({ where });
|
||||
|
||||
// Line 1401: 메뉴 할당 삭제
|
||||
await prisma.screen_menu_assignments.deleteMany({ where });
|
||||
```
|
||||
|
||||
### 5. 테이블 레이블 (Table Labels) - 3개
|
||||
|
||||
```typescript
|
||||
// Line 117: 테이블 레이블 조회 (페이징)
|
||||
await prisma.table_labels.findMany({ where, skip, take });
|
||||
|
||||
// Line 713: 테이블 레이블 조회 (전체)
|
||||
await prisma.table_labels.findMany({ where });
|
||||
```
|
||||
|
||||
### 6. 컬럼 레이블 (Column Labels) - 2개
|
||||
|
||||
```typescript
|
||||
// Line 948: 웹타입 정보 조회
|
||||
await prisma.column_labels.findMany({ where, select });
|
||||
|
||||
// Line 1456: 컬럼 레이블 UPSERT
|
||||
await prisma.column_labels.upsert({ where, create, update });
|
||||
```
|
||||
|
||||
### 7. Raw Query 사용 (이미 있음) - 6개
|
||||
|
||||
```typescript
|
||||
// Line 627: 화면 순서 변경 (일괄 업데이트)
|
||||
await prisma.$executeRaw`UPDATE screen_definitions SET display_order = ...`;
|
||||
|
||||
// Line 833: 테이블 목록 조회
|
||||
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||
|
||||
// Line 876: 테이블 존재 확인
|
||||
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||
|
||||
// Line 922: 테이블 컬럼 정보 조회
|
||||
await prisma.$queryRaw<Array<ColumnInfo>>`SELECT column_name, data_type ...`;
|
||||
|
||||
// Line 1418: 컬럼 정보 조회 (상세)
|
||||
await prisma.$queryRaw`SELECT column_name, data_type ...`;
|
||||
```
|
||||
|
||||
### 8. 트랜잭션 사용 - 3개
|
||||
|
||||
```typescript
|
||||
// Line 521: 화면 템플릿 적용 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 593: 화면 복사 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 788: 일괄 삭제 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 1697: 위젯 데이터 저장 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 전략
|
||||
|
||||
### 전략 1: 단계적 전환
|
||||
|
||||
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||
2. **2단계**: 복잡한 조회 전환 (include, join)
|
||||
3. **3단계**: 트랜잭션 전환
|
||||
4. **4단계**: Raw Query 개선
|
||||
|
||||
### 전략 2: 함수별 전환 우선순위
|
||||
|
||||
#### 🔴 최우선 (기본 CRUD)
|
||||
|
||||
- `createScreen()` - Line 70
|
||||
- `getScreensByCompany()` - Line 99-105
|
||||
- `getScreenByCode()` - Line 178
|
||||
- `getScreenById()` - Line 205
|
||||
- `updateScreen()` - Line 236
|
||||
- `deleteScreen()` - Line 672
|
||||
|
||||
#### 🟡 2순위 (레이아웃)
|
||||
|
||||
- `saveLayout()` - Line 1096-1152
|
||||
- `getLayout()` - Line 1193
|
||||
- `deleteLayout()` - Line 1096
|
||||
|
||||
#### 🟢 3순위 (템플릿 & 메뉴)
|
||||
|
||||
- `getTemplates()` - Line 1303
|
||||
- `createTemplate()` - Line 1317
|
||||
- `assignToMenu()` - Line 1358
|
||||
- `getMenuAssignments()` - Line 1376
|
||||
- `removeMenuAssignment()` - Line 1401
|
||||
|
||||
#### 🔵 4순위 (복잡한 기능)
|
||||
|
||||
- `copyScreen()` - Line 593 (트랜잭션)
|
||||
- `applyTemplate()` - Line 521 (트랜잭션)
|
||||
- `bulkDelete()` - Line 788 (트랜잭션)
|
||||
- `reorderScreens()` - Line 627 (Raw Query)
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: createScreen() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 53: 중복 확인
|
||||
const existingScreen = await prisma.screen_definitions.findFirst({
|
||||
where: {
|
||||
screen_code: screenData.screenCode,
|
||||
is_active: { not: "D" },
|
||||
},
|
||||
});
|
||||
|
||||
// Line 70: 생성
|
||||
const screen = await prisma.screen_definitions.create({
|
||||
data: {
|
||||
screen_name: screenData.screenName,
|
||||
screen_code: screenData.screenCode,
|
||||
table_name: screenData.tableName,
|
||||
company_code: screenData.companyCode,
|
||||
description: screenData.description,
|
||||
created_by: screenData.createdBy,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
// 중복 확인
|
||||
const existingResult = await query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND is_active != 'D'
|
||||
LIMIT 1`,
|
||||
[screenData.screenCode]
|
||||
);
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 생성
|
||||
const [screen] = await query<ScreenDefinition>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
screenData.screenCode,
|
||||
screenData.tableName,
|
||||
screenData.companyCode,
|
||||
screenData.description,
|
||||
screenData.createdBy,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: getScreensByCompany() 전환 (페이징)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const [screens, total] = await Promise.all([
|
||||
prisma.screen_definitions.findMany({
|
||||
where: whereClause,
|
||||
skip: (page - 1) * size,
|
||||
take: size,
|
||||
orderBy: { created_at: "desc" },
|
||||
}),
|
||||
prisma.screen_definitions.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
const offset = (page - 1) * size;
|
||||
const whereSQL =
|
||||
companyCode !== "*"
|
||||
? "WHERE company_code = $1 AND is_active != 'D'"
|
||||
: "WHERE is_active != 'D'";
|
||||
const params =
|
||||
companyCode !== "*" ? [companyCode, size, offset] : [size, offset];
|
||||
|
||||
const [screens, totalResult] = await Promise.all([
|
||||
query<ScreenDefinition>(
|
||||
`SELECT * FROM screen_definitions
|
||||
${whereSQL}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
),
|
||||
query<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM screen_definitions ${whereSQL}`,
|
||||
companyCode !== "*" ? [companyCode] : []
|
||||
),
|
||||
]);
|
||||
|
||||
const total = totalResult[0]?.count || 0;
|
||||
```
|
||||
|
||||
### 예시 3: 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const newScreen = await tx.screen_definitions.create({ data: { ... } });
|
||||
await tx.screen_layouts.createMany({ data: layouts });
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
const [newScreen] = await client.query(
|
||||
`INSERT INTO screen_definitions (...) VALUES (...) RETURNING *`,
|
||||
[...]
|
||||
);
|
||||
|
||||
for (const layout of layouts) {
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
```typescript
|
||||
describe("ScreenManagementService Raw Query 전환 테스트", () => {
|
||||
describe("createScreen", () => {
|
||||
test("화면 생성 성공", async () => { ... });
|
||||
test("중복 화면 코드 에러", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getScreensByCompany", () => {
|
||||
test("페이징 조회 성공", async () => { ... });
|
||||
test("회사별 필터링", async () => { ... });
|
||||
});
|
||||
|
||||
describe("copyScreen", () => {
|
||||
test("화면 복사 성공 (트랜잭션)", async () => { ... });
|
||||
test("레이아웃 함께 복사", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
```typescript
|
||||
describe("화면 관리 통합 테스트", () => {
|
||||
test("화면 생성 → 조회 → 수정 → 삭제", async () => { ... });
|
||||
test("화면 복사 → 레이아웃 확인", async () => { ... });
|
||||
test("메뉴 할당 → 조회 → 해제", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createScreen()` - 화면 생성
|
||||
- [x] `getScreensByCompany()` - 화면 목록 (페이징)
|
||||
- [x] `getScreenByCode()` - 화면 코드로 조회
|
||||
- [x] `getScreenById()` - 화면 ID로 조회
|
||||
- [x] `updateScreen()` - 화면 업데이트
|
||||
- [x] `deleteScreen()` - 화면 삭제
|
||||
- [x] `getScreens()` - 전체 화면 목록 조회
|
||||
- [x] `getScreen()` - 회사 코드 필터링 포함 조회
|
||||
|
||||
### 2단계: 레이아웃 관리 (2개 함수) ✅ **완료**
|
||||
|
||||
- [x] `saveLayout()` - 레이아웃 저장 (메타데이터 + 컴포넌트)
|
||||
- [x] `getLayout()` - 레이아웃 조회
|
||||
- [x] 레이아웃 삭제 로직 (saveLayout 내부에 포함)
|
||||
|
||||
### 3단계: 템플릿 & 메뉴 (5개 함수) ✅ **완료**
|
||||
|
||||
- [x] `getTemplatesByCompany()` - 템플릿 목록
|
||||
- [x] `createTemplate()` - 템플릿 생성
|
||||
- [x] `assignScreenToMenu()` - 메뉴 할당
|
||||
- [x] `getScreensByMenu()` - 메뉴별 화면 조회
|
||||
- [x] `unassignScreenFromMenu()` - 메뉴 할당 해제
|
||||
- [ ] 테이블 레이블 조회 (getScreensByCompany 내부에 포함됨)
|
||||
|
||||
### 4단계: 복잡한 기능 (4개 함수) ✅ **완료**
|
||||
|
||||
- [x] `copyScreen()` - 화면 복사 (트랜잭션)
|
||||
- [x] `generateScreenCode()` - 화면 코드 자동 생성
|
||||
- [x] `checkScreenDependencies()` - 화면 의존성 체크 (메뉴 할당 포함)
|
||||
- [x] 모든 유틸리티 메서드 Raw Query 전환
|
||||
|
||||
### 5단계: 테스트 & 검증 ✅ **완료**
|
||||
|
||||
- [x] 단위 테스트 작성 (18개 테스트 통과)
|
||||
- createScreen, updateScreen, deleteScreen
|
||||
- getScreensByCompany, getScreenById
|
||||
- saveLayout, getLayout
|
||||
- getTemplatesByCompany, assignScreenToMenu
|
||||
- copyScreen, generateScreenCode
|
||||
- getTableColumns
|
||||
- [x] 통합 테스트 작성 (6개 시나리오)
|
||||
- 화면 생명주기 테스트 (생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제)
|
||||
- 화면 복사 및 레이아웃 테스트
|
||||
- 테이블 정보 조회 테스트
|
||||
- 일괄 작업 테스트
|
||||
- 화면 코드 자동 생성 테스트
|
||||
- [x] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트 (추후 실행 예정)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- ✅ **46개 Prisma 호출 모두 Raw Query로 전환 완료**
|
||||
- ✅ **모든 TypeScript 컴파일 오류 해결**
|
||||
- ✅ **트랜잭션 정상 동작 확인**
|
||||
- ✅ **에러 처리 및 롤백 정상 동작**
|
||||
- ✅ **모든 단위 테스트 통과 (18개)**
|
||||
- ✅ **모든 통합 테스트 작성 완료 (6개 시나리오)**
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
- [ ] 성능 저하 없음 (기존 대비 ±10% 이내) - 추후 측정 예정
|
||||
|
||||
## 📊 테스트 결과
|
||||
|
||||
### 단위 테스트 (18개)
|
||||
|
||||
```
|
||||
✅ createScreen - 화면 생성 (2개 테스트)
|
||||
✅ getScreensByCompany - 화면 목록 페이징 (2개 테스트)
|
||||
✅ updateScreen - 화면 업데이트 (2개 테스트)
|
||||
✅ deleteScreen - 화면 삭제 (2개 테스트)
|
||||
✅ saveLayout - 레이아웃 저장 (2개 테스트)
|
||||
- 기본 저장, 소수점 좌표 반올림 처리
|
||||
✅ getLayout - 레이아웃 조회 (1개 테스트)
|
||||
✅ getTemplatesByCompany - 템플릿 목록 (1개 테스트)
|
||||
✅ assignScreenToMenu - 메뉴 할당 (2개 테스트)
|
||||
✅ copyScreen - 화면 복사 (1개 테스트)
|
||||
✅ generateScreenCode - 화면 코드 자동 생성 (2개 테스트)
|
||||
✅ getTableColumns - 테이블 컬럼 정보 (1개 테스트)
|
||||
|
||||
Test Suites: 1 passed
|
||||
Tests: 18 passed
|
||||
Time: 1.922s
|
||||
```
|
||||
|
||||
### 통합 테스트 (6개 시나리오)
|
||||
|
||||
```
|
||||
✅ 화면 생명주기 테스트
|
||||
- 생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제
|
||||
✅ 화면 복사 및 레이아웃 테스트
|
||||
- 화면 복사 → 레이아웃 저장 → 레이아웃 확인 → 레이아웃 수정
|
||||
✅ 테이블 정보 조회 테스트
|
||||
- 테이블 목록 조회 → 특정 테이블 정보 조회
|
||||
✅ 일괄 작업 테스트
|
||||
- 여러 화면 생성 → 일괄 삭제
|
||||
✅ 화면 코드 자동 생성 테스트
|
||||
- 순차적 화면 코드 생성 검증
|
||||
✅ 메뉴 할당 테스트 (skip - 실제 메뉴 데이터 필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 버그 수정 및 개선사항
|
||||
|
||||
### 실제 운영 환경에서 발견된 이슈
|
||||
|
||||
#### 1. 소수점 좌표 저장 오류 (해결 완료)
|
||||
|
||||
**문제**:
|
||||
|
||||
```
|
||||
invalid input syntax for type integer: "1602.666666666667"
|
||||
```
|
||||
|
||||
- `position_x`, `position_y`, `width`, `height` 컬럼이 `integer` 타입
|
||||
- 격자 계산 시 소수점 값이 발생하여 저장 실패
|
||||
|
||||
**해결**:
|
||||
|
||||
```typescript
|
||||
Math.round(component.position.x), // 정수로 반올림
|
||||
Math.round(component.position.y),
|
||||
Math.round(component.size.width),
|
||||
Math.round(component.size.height),
|
||||
```
|
||||
|
||||
**테스트 추가**:
|
||||
|
||||
- 소수점 좌표 저장 테스트 케이스 추가
|
||||
- 반올림 처리 검증
|
||||
|
||||
**영향 범위**:
|
||||
|
||||
- `saveLayout()` 함수
|
||||
- `copyScreen()` 함수 (레이아웃 복사 시)
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-09-30
|
||||
**예상 소요 시간**: 2-3일 → **실제 소요 시간**: 1일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🔴 최우선 (Phase 2.1)
|
||||
**상태**: ✅ **완료**
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
# 📋 Phase 3.11: DDLAuditLogger Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLAuditLogger는 **8개의 Prisma 호출**이 있으며, DDL 실행 감사 로그 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlAuditLogger.ts` |
|
||||
| 파일 크기 | 350 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (통계 쿼리, $executeRaw) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.11) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ DDL 감사 로그 기능 정상 동작
|
||||
- ⏳ 통계 쿼리 전환 (GROUP BY, COUNT, ORDER BY)
|
||||
- ⏳ $executeRaw → query 전환
|
||||
- ⏳ $queryRawUnsafe → query 전환
|
||||
- ⏳ 동적 WHERE 조건 생성
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (8개)
|
||||
|
||||
#### 1. **logDDLStart()** - DDL 시작 로그 (INSERT)
|
||||
|
||||
```typescript
|
||||
// Line 27
|
||||
const logEntry = await prisma.$executeRaw`
|
||||
INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES (
|
||||
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||
)
|
||||
`;
|
||||
```
|
||||
|
||||
#### 2. **getAuditLogs()** - 감사 로그 목록 조회 (SELECT with filters)
|
||||
|
||||
```typescript
|
||||
// Line 162
|
||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||
```
|
||||
|
||||
- 동적 WHERE 조건 생성
|
||||
- 페이징 (OFFSET, LIMIT)
|
||||
- 정렬 (ORDER BY)
|
||||
|
||||
#### 3. **getAuditStats()** - 통계 조회 (복합 쿼리)
|
||||
|
||||
```typescript
|
||||
// Line 199 - 총 통계
|
||||
const totalStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT
|
||||
COUNT(*) as total_executions,
|
||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
|
||||
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
||||
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}`
|
||||
)) as any[];
|
||||
|
||||
// Line 212 - DDL 타입별 통계
|
||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`
|
||||
)) as any[];
|
||||
|
||||
// Line 224 - 사용자별 통계
|
||||
const userStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT executed_by, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY executed_by
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`
|
||||
)) as any[];
|
||||
|
||||
// Line 237 - 최근 실패 로그
|
||||
const recentFailures = (await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ddl_audit_logs
|
||||
WHERE status = 'failed' AND ${whereClause}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 5`
|
||||
)) as any[];
|
||||
```
|
||||
|
||||
#### 4. **getExecutionHistory()** - 실행 이력 조회
|
||||
|
||||
```typescript
|
||||
// Line 287
|
||||
const history = await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ddl_audit_logs
|
||||
WHERE table_name = $1 AND company_code = $2
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $3`,
|
||||
tableName,
|
||||
companyCode,
|
||||
limit
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. **cleanupOldLogs()** - 오래된 로그 삭제
|
||||
|
||||
```typescript
|
||||
// Line 320
|
||||
const result = await prisma.$executeRaw`
|
||||
DELETE FROM ddl_audit_logs
|
||||
WHERE started_at < NOW() - INTERVAL '${retentionDays} days'
|
||||
AND company_code = ${companyCode}
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: $executeRaw 전환 (2개)
|
||||
|
||||
- `logDDLStart()` - INSERT
|
||||
- `cleanupOldLogs()` - DELETE
|
||||
|
||||
### 2단계: 단순 $queryRawUnsafe 전환 (1개)
|
||||
|
||||
- `getExecutionHistory()` - 파라미터 바인딩 있음
|
||||
|
||||
### 3단계: 복잡한 $queryRawUnsafe 전환 (1개)
|
||||
|
||||
- `getAuditLogs()` - 동적 WHERE 조건
|
||||
|
||||
### 4단계: 통계 쿼리 전환 (4개)
|
||||
|
||||
- `getAuditStats()` 내부의 4개 쿼리
|
||||
- GROUP BY, CASE WHEN, AVG, EXTRACT
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: $executeRaw → query (INSERT)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const logEntry = await prisma.$executeRaw`
|
||||
INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES (
|
||||
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||
)
|
||||
`;
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
await query(
|
||||
`INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::jsonb)`,
|
||||
[
|
||||
executionId,
|
||||
ddlType,
|
||||
tableName,
|
||||
"in_progress",
|
||||
executedBy,
|
||||
companyCode,
|
||||
JSON.stringify(metadata),
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 동적 WHERE 조건
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
let query = `SELECT * FROM ddl_audit_logs WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.ddlType) {
|
||||
query += ` AND ddl_type = ?`;
|
||||
params.push(filters.ddlType);
|
||||
}
|
||||
|
||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.ddlType) {
|
||||
conditions.push(`ddl_type = $${paramIndex++}`);
|
||||
params.push(filters.ddlType);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `SELECT * FROM ddl_audit_logs ${whereClause}`;
|
||||
|
||||
const logs = await query<any>(sql, params);
|
||||
```
|
||||
|
||||
### 예시 3: 통계 쿼리 (GROUP BY)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`
|
||||
)) as any[];
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const ddlTypeStats = await query<{ ddl_type: string; count: string }>(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
`metadata` 필드는 JSONB 타입으로, INSERT 시 `::jsonb` 캐스팅 필요:
|
||||
|
||||
```typescript
|
||||
JSON.stringify(metadata) + "::jsonb";
|
||||
```
|
||||
|
||||
### 2. 날짜/시간 함수
|
||||
|
||||
- `NOW()` - 현재 시간
|
||||
- `INTERVAL '30 days'` - 날짜 간격
|
||||
- `EXTRACT(EPOCH FROM ...)` - 초 단위 변환
|
||||
|
||||
### 3. CASE WHEN 집계
|
||||
|
||||
```sql
|
||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful
|
||||
```
|
||||
|
||||
### 4. 동적 WHERE 조건
|
||||
|
||||
여러 필터를 조합하여 WHERE 절 생성:
|
||||
|
||||
- ddlType
|
||||
- tableName
|
||||
- status
|
||||
- executedBy
|
||||
- dateRange (startDate, endDate)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (8개)
|
||||
|
||||
1. **`logDDLExecution()`** - DDL 실행 로그 INSERT
|
||||
- Before: `prisma.$executeRaw`
|
||||
- After: `query()` with 7 parameters
|
||||
2. **`getAuditLogs()`** - 감사 로그 목록 조회
|
||||
- Before: `prisma.$queryRawUnsafe`
|
||||
- After: `query<any>()` with dynamic WHERE clause
|
||||
3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리)
|
||||
- Before: 4x `prisma.$queryRawUnsafe`
|
||||
- After: 4x `query<any>()`
|
||||
- totalStats: 전체 실행 통계 (CASE WHEN 집계)
|
||||
- ddlTypeStats: DDL 타입별 통계 (GROUP BY)
|
||||
- userStats: 사용자별 통계 (GROUP BY, LIMIT 10)
|
||||
- recentFailures: 최근 실패 로그 (WHERE success = false)
|
||||
4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리
|
||||
- Before: `prisma.$queryRawUnsafe`
|
||||
- After: `query<any>()` with table_name filter
|
||||
5. **`cleanupOldLogs()`** - 오래된 로그 삭제
|
||||
- Before: `prisma.$executeRaw`
|
||||
- After: `query()` with date filter
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
1. **파라미터 바인딩**: PostgreSQL `$1, $2, ...` 스타일로 통일
|
||||
2. **동적 WHERE 조건**: 파라미터 인덱스 자동 증가 로직 유지
|
||||
3. **통계 쿼리**: CASE WHEN, GROUP BY, SUM 등 복잡한 집계 쿼리 완벽 전환
|
||||
4. **에러 처리**: 기존 try-catch 구조 유지
|
||||
5. **로깅**: logger 유틸리티 활용 유지
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] `import { PrismaClient }` 제거
|
||||
- [x] `const prisma = new PrismaClient()` 제거
|
||||
- [x] `import { query, queryOne }` 추가
|
||||
- [x] 모든 타입 정의 유지
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `logDDLStart()` - INSERT ($executeRaw → query)
|
||||
- [ ] `logDDLComplete()` - UPDATE (이미 query 사용 중일 가능성)
|
||||
- [ ] `logDDLError()` - UPDATE (이미 query 사용 중일 가능성)
|
||||
- [ ] `getAuditLogs()` - SELECT with filters ($queryRawUnsafe → query)
|
||||
- [ ] `getAuditStats()` 내 4개 쿼리:
|
||||
- [ ] totalStats (집계 쿼리)
|
||||
- [ ] ddlTypeStats (GROUP BY)
|
||||
- [ ] userStats (GROUP BY + LIMIT)
|
||||
- [ ] recentFailures (필터 + ORDER BY + LIMIT)
|
||||
- [ ] `getExecutionHistory()` - SELECT with params ($queryRawUnsafe → query)
|
||||
- [ ] `cleanupOldLogs()` - DELETE ($executeRaw → query)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] Prisma import 완전 제거
|
||||
- [ ] 타입 정의 확인
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (8개)
|
||||
- [ ] DDL 시작 로그 테스트
|
||||
- [ ] DDL 완료 로그 테스트
|
||||
- [ ] 감사 로그 목록 조회 테스트
|
||||
- [ ] 통계 조회 테스트
|
||||
- [ ] 실행 이력 조회 테스트
|
||||
- [ ] 오래된 로그 삭제 테스트
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] 전체 DDL 실행 플로우 테스트
|
||||
- [ ] 필터링 및 페이징 테스트
|
||||
- [ ] 통계 정확성 테스트
|
||||
- [ ] 성능 테스트
|
||||
- [ ] 대량 로그 조회 성능
|
||||
- [ ] 통계 쿼리 성능
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] 주요 변경사항 기록
|
||||
- [ ] 성능 벤치마크 결과
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- 복잡한 통계 쿼리 (GROUP BY, CASE WHEN)
|
||||
- 동적 WHERE 조건 생성
|
||||
- JSON 필드 처리
|
||||
- **예상 소요 시간**: 1~1.5시간
|
||||
- Prisma 호출 전환: 30분
|
||||
- 테스트: 20분
|
||||
- 문서화: 10분
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### 관련 서비스
|
||||
|
||||
- `DDLExecutionService` - DDL 실행 (이미 전환 완료)
|
||||
- `DDLSafetyValidator` - DDL 안전성 검증
|
||||
|
||||
### 의존성
|
||||
|
||||
- `../database/db` - query, queryOne 함수
|
||||
- `../types/ddl` - DDL 관련 타입
|
||||
- `../utils/logger` - 로깅
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 통계 쿼리, JSON 필드, 동적 WHERE 조건 포함
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
# 📋 Phase 3.12: ExternalCallConfigService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ExternalCallConfigService는 **8개의 Prisma 호출**이 있으며, 외부 API 호출 설정 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/externalCallConfigService.ts` |
|
||||
| 파일 크기 | 612 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (JSON 필드, 복잡한 CRUD) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.12) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 외부 호출 설정 CRUD 기능 정상 동작
|
||||
- ⏳ JSON 필드 처리 (headers, params, auth_config)
|
||||
- ⏳ 동적 WHERE 조건 생성
|
||||
- ⏳ 민감 정보 암호화/복호화 유지
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (8개 예상)
|
||||
|
||||
#### 1. **외부 호출 설정 목록 조회**
|
||||
|
||||
- findMany with filters
|
||||
- 페이징, 정렬
|
||||
- 동적 WHERE 조건 (is_active, company_code, search)
|
||||
|
||||
#### 2. **외부 호출 설정 단건 조회**
|
||||
|
||||
- findUnique or findFirst
|
||||
- config_id 기준
|
||||
|
||||
#### 3. **외부 호출 설정 생성**
|
||||
|
||||
- create
|
||||
- JSON 필드 처리 (headers, params, auth_config)
|
||||
- 민감 정보 암호화
|
||||
|
||||
#### 4. **외부 호출 설정 수정**
|
||||
|
||||
- update
|
||||
- 동적 UPDATE 쿼리
|
||||
- JSON 필드 업데이트
|
||||
|
||||
#### 5. **외부 호출 설정 삭제**
|
||||
|
||||
- delete or soft delete
|
||||
|
||||
#### 6. **외부 호출 설정 복제**
|
||||
|
||||
- findUnique + create
|
||||
|
||||
#### 7. **외부 호출 설정 테스트**
|
||||
|
||||
- findUnique
|
||||
- 실제 HTTP 호출
|
||||
|
||||
#### 8. **외부 호출 이력 조회**
|
||||
|
||||
- findMany with 관계 조인
|
||||
- 통계 쿼리
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개)
|
||||
|
||||
- getExternalCallConfigs() - 목록 조회
|
||||
- getExternalCallConfig() - 단건 조회
|
||||
- createExternalCallConfig() - 생성
|
||||
- updateExternalCallConfig() - 수정
|
||||
- deleteExternalCallConfig() - 삭제
|
||||
|
||||
### 2단계: 추가 기능 전환 (3개)
|
||||
|
||||
- duplicateExternalCallConfig() - 복제
|
||||
- testExternalCallConfig() - 테스트
|
||||
- getExternalCallHistory() - 이력 조회
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 목록 조회 (동적 WHERE + JSON)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const configs = await prisma.external_call_configs.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
is_active: isActive,
|
||||
OR: [
|
||||
{ config_name: { contains: search, mode: "insensitive" } },
|
||||
{ endpoint_url: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(`is_active = $${paramIndex++}`);
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(config_name ILIKE $${paramIndex} OR endpoint_url ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const configs = await query<any>(
|
||||
`SELECT * FROM external_call_configs
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...params, limit, skip]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: JSON 필드 생성
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const config = await prisma.external_call_configs.create({
|
||||
data: {
|
||||
config_name: data.config_name,
|
||||
endpoint_url: data.endpoint_url,
|
||||
http_method: data.http_method,
|
||||
headers: data.headers, // JSON
|
||||
params: data.params, // JSON
|
||||
auth_config: encryptedAuthConfig, // JSON (암호화됨)
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const config = await queryOne<any>(
|
||||
`INSERT INTO external_call_configs
|
||||
(config_name, endpoint_url, http_method, headers, params,
|
||||
auth_config, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.config_name,
|
||||
data.endpoint_url,
|
||||
data.http_method,
|
||||
JSON.stringify(data.headers),
|
||||
JSON.stringify(data.params),
|
||||
JSON.stringify(encryptedAuthConfig),
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 UPDATE (JSON 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const updateData: any = {};
|
||||
if (data.headers) updateData.headers = data.headers;
|
||||
if (data.params) updateData.params = data.params;
|
||||
|
||||
const config = await prisma.external_call_configs.update({
|
||||
where: { config_id: configId },
|
||||
data: updateData,
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.headers !== undefined) {
|
||||
updateFields.push(`headers = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.headers));
|
||||
}
|
||||
|
||||
if (data.params !== undefined) {
|
||||
updateFields.push(`params = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.params));
|
||||
}
|
||||
|
||||
const config = await queryOne<any>(
|
||||
`UPDATE external_call_configs
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE config_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, configId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
3개의 JSON 필드가 있을 것으로 예상:
|
||||
|
||||
- `headers` - HTTP 헤더
|
||||
- `params` - 쿼리 파라미터
|
||||
- `auth_config` - 인증 설정 (암호화됨)
|
||||
|
||||
```typescript
|
||||
// INSERT/UPDATE 시
|
||||
JSON.stringify(jsonData);
|
||||
|
||||
// SELECT 후
|
||||
const parsedData =
|
||||
typeof row.headers === "string" ? JSON.parse(row.headers) : row.headers;
|
||||
```
|
||||
|
||||
### 2. 민감 정보 암호화
|
||||
|
||||
auth_config는 암호화되어 저장되므로, 기존 암호화/복호화 로직 유지:
|
||||
|
||||
```typescript
|
||||
import { encrypt, decrypt } from "../utils/encryption";
|
||||
|
||||
// 저장 시
|
||||
const encryptedAuthConfig = encrypt(JSON.stringify(authConfig));
|
||||
|
||||
// 조회 시
|
||||
const decryptedAuthConfig = JSON.parse(decrypt(row.auth_config));
|
||||
```
|
||||
|
||||
### 3. HTTP 메소드 검증
|
||||
|
||||
```typescript
|
||||
const VALID_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
if (!VALID_HTTP_METHODS.includes(httpMethod)) {
|
||||
throw new Error("Invalid HTTP method");
|
||||
}
|
||||
```
|
||||
|
||||
### 4. URL 검증
|
||||
|
||||
```typescript
|
||||
try {
|
||||
new URL(endpointUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid endpoint URL");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (8개)
|
||||
|
||||
1. **`getConfigs()`** - 목록 조회 (findMany → query)
|
||||
2. **`getConfigById()`** - 단건 조회 (findUnique → queryOne)
|
||||
3. **`createConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||
4. **`createConfig()`** - 생성 (create → queryOne with INSERT)
|
||||
5. **`updateConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||
6. **`updateConfig()`** - 수정 (update → queryOne with 동적 UPDATE)
|
||||
7. **`deleteConfig()`** - 삭제 (update → query)
|
||||
8. **`getExternalCallConfigsForButtonControl()`** - 조회 (findMany → query)
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
- 동적 WHERE 조건 생성 (company_code, call_type, api_type, is_active, search)
|
||||
- ILIKE를 활용한 대소문자 구분 없는 검색
|
||||
- 동적 UPDATE 쿼리 (9개 필드)
|
||||
- JSON 필드 처리 (`config_data` → `JSON.stringify()`)
|
||||
- 중복 검사 로직 유지
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] import 문 수정 완료
|
||||
- [x] Prisma import 완전 제거
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `getExternalCallConfigs()` - 목록 조회 (findMany + count)
|
||||
- [ ] `getExternalCallConfig()` - 단건 조회 (findUnique)
|
||||
- [ ] `createExternalCallConfig()` - 생성 (create)
|
||||
- [ ] `updateExternalCallConfig()` - 수정 (update)
|
||||
- [ ] `deleteExternalCallConfig()` - 삭제 (delete)
|
||||
- [ ] `duplicateExternalCallConfig()` - 복제 (findUnique + create)
|
||||
- [ ] `testExternalCallConfig()` - 테스트 (findUnique)
|
||||
- [ ] `getExternalCallHistory()` - 이력 조회 (findMany)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] JSON 필드 처리 확인
|
||||
- [ ] 암호화/복호화 로직 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (8개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] 암호화 테스트
|
||||
- [ ] HTTP 호출 테스트
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] API 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- JSON 필드 처리
|
||||
- 암호화/복호화 로직
|
||||
- HTTP 호출 테스트
|
||||
- **예상 소요 시간**: 1~1.5시간
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드, 민감 정보 암호화, HTTP 호출 포함
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
# 📋 Phase 3.13: EntityJoinService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
EntityJoinService는 **5개의 Prisma 호출**이 있으며, 엔티티 간 조인 관계 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/entityJoinService.ts` |
|
||||
| 파일 크기 | 575 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (조인 쿼리, 관계 설정) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.13) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 엔티티 조인 설정 CRUD 기능 정상 동작
|
||||
- ⏳ 복잡한 조인 쿼리 전환 (LEFT JOIN, INNER JOIN)
|
||||
- ⏳ 조인 유효성 검증
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (5개 예상)
|
||||
|
||||
#### 1. **엔티티 조인 목록 조회**
|
||||
|
||||
- findMany with filters
|
||||
- 동적 WHERE 조건
|
||||
- 페이징, 정렬
|
||||
|
||||
#### 2. **엔티티 조인 단건 조회**
|
||||
|
||||
- findUnique or findFirst
|
||||
- join_id 기준
|
||||
|
||||
#### 3. **엔티티 조인 생성**
|
||||
|
||||
- create
|
||||
- 조인 유효성 검증
|
||||
|
||||
#### 4. **엔티티 조인 수정**
|
||||
|
||||
- update
|
||||
- 동적 UPDATE 쿼리
|
||||
|
||||
#### 5. **엔티티 조인 삭제**
|
||||
|
||||
- delete
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개)
|
||||
|
||||
- getEntityJoins() - 목록 조회
|
||||
- getEntityJoin() - 단건 조회
|
||||
- createEntityJoin() - 생성
|
||||
- updateEntityJoin() - 수정
|
||||
- deleteEntityJoin() - 삭제
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 조인 설정 조회 (LEFT JOIN으로 테이블 정보 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const joins = await prisma.entity_joins.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
is_active: true,
|
||||
},
|
||||
include: {
|
||||
source_table: true,
|
||||
target_table: true,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const joins = await query<any>(
|
||||
`SELECT
|
||||
ej.*,
|
||||
st.table_name as source_table_name,
|
||||
st.table_label as source_table_label,
|
||||
tt.table_name as target_table_name,
|
||||
tt.table_label as target_table_label
|
||||
FROM entity_joins ej
|
||||
LEFT JOIN tables st ON ej.source_table_id = st.table_id
|
||||
LEFT JOIN tables tt ON ej.target_table_id = tt.table_id
|
||||
WHERE ej.company_code = $1 AND ej.is_active = $2
|
||||
ORDER BY ej.created_at DESC`,
|
||||
[companyCode, true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 조인 생성 (유효성 검증 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// 조인 유효성 검증
|
||||
const sourceTable = await prisma.tables.findUnique({
|
||||
where: { table_id: sourceTableId },
|
||||
});
|
||||
|
||||
const targetTable = await prisma.tables.findUnique({
|
||||
where: { table_id: targetTableId },
|
||||
});
|
||||
|
||||
if (!sourceTable || !targetTable) {
|
||||
throw new Error("Invalid table references");
|
||||
}
|
||||
|
||||
// 조인 생성
|
||||
const join = await prisma.entity_joins.create({
|
||||
data: {
|
||||
source_table_id: sourceTableId,
|
||||
target_table_id: targetTableId,
|
||||
join_type: joinType,
|
||||
join_condition: joinCondition,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 조인 유효성 검증 (Promise.all로 병렬 실행)
|
||||
const [sourceTable, targetTable] = await Promise.all([
|
||||
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [sourceTableId]),
|
||||
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [targetTableId]),
|
||||
]);
|
||||
|
||||
if (!sourceTable || !targetTable) {
|
||||
throw new Error("Invalid table references");
|
||||
}
|
||||
|
||||
// 조인 생성
|
||||
const join = await queryOne<any>(
|
||||
`INSERT INTO entity_joins
|
||||
(source_table_id, target_table_id, join_type, join_condition,
|
||||
company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[sourceTableId, targetTableId, joinType, joinCondition, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 조인 수정
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const join = await prisma.entity_joins.update({
|
||||
where: { join_id: joinId },
|
||||
data: {
|
||||
join_type: joinType,
|
||||
join_condition: joinCondition,
|
||||
is_active: isActive,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (joinType !== undefined) {
|
||||
updateFields.push(`join_type = $${paramIndex++}`);
|
||||
values.push(joinType);
|
||||
}
|
||||
|
||||
if (joinCondition !== undefined) {
|
||||
updateFields.push(`join_condition = $${paramIndex++}`);
|
||||
values.push(joinCondition);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(isActive);
|
||||
}
|
||||
|
||||
const join = await queryOne<any>(
|
||||
`UPDATE entity_joins
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE join_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, joinId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 조인 타입 검증
|
||||
|
||||
```typescript
|
||||
const VALID_JOIN_TYPES = ["INNER", "LEFT", "RIGHT", "FULL"];
|
||||
if (!VALID_JOIN_TYPES.includes(joinType)) {
|
||||
throw new Error("Invalid join type");
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 조인 조건 검증
|
||||
|
||||
```typescript
|
||||
// 조인 조건은 SQL 조건식 형태 (예: "source.id = target.parent_id")
|
||||
// SQL 인젝션 방지를 위한 검증 필요
|
||||
const isValidJoinCondition = /^[\w\s.=<>]+$/.test(joinCondition);
|
||||
if (!isValidJoinCondition) {
|
||||
throw new Error("Invalid join condition");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 순환 참조 방지
|
||||
|
||||
```typescript
|
||||
// 조인이 순환 참조를 만들지 않는지 검증
|
||||
async function checkCircularReference(
|
||||
sourceTableId: number,
|
||||
targetTableId: number
|
||||
): Promise<boolean> {
|
||||
// 재귀적으로 조인 관계 확인
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. LEFT JOIN으로 관련 테이블 정보 조회
|
||||
|
||||
조인 설정 조회 시 source/target 테이블 정보를 함께 가져오기 위해 LEFT JOIN 사용
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (5개)
|
||||
|
||||
1. **`detectEntityJoins()`** - 엔티티 컬럼 감지 (findMany → query)
|
||||
|
||||
- column_labels 조회
|
||||
- web_type = 'entity' 필터
|
||||
- reference_table/reference_column IS NOT NULL
|
||||
|
||||
2. **`validateJoinConfig()`** - 테이블 존재 확인 ($queryRaw → query)
|
||||
|
||||
- information_schema.tables 조회
|
||||
- 참조 테이블 검증
|
||||
|
||||
3. **`validateJoinConfig()`** - 컬럼 존재 확인 ($queryRaw → query)
|
||||
|
||||
- information_schema.columns 조회
|
||||
- 표시 컬럼 검증
|
||||
|
||||
4. **`getReferenceTableColumns()`** - 컬럼 정보 조회 ($queryRaw → query)
|
||||
|
||||
- information_schema.columns 조회
|
||||
- 문자열 타입 컬럼만 필터
|
||||
|
||||
5. **`getReferenceTableColumns()`** - 라벨 정보 조회 (findMany → query)
|
||||
- column_labels 조회
|
||||
- 컬럼명과 라벨 매핑
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
- **information_schema 쿼리**: 파라미터 바인딩으로 변경 ($1, $2)
|
||||
- **타입 안전성**: 명확한 반환 타입 지정
|
||||
- **IS NOT NULL 조건**: Prisma의 { not: null } → IS NOT NULL
|
||||
- **IN 조건**: 여러 데이터 타입 필터링
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] PrismaClient import 제거
|
||||
- [x] import 문 수정 완료
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `getEntityJoins()` - 목록 조회 (findMany with include)
|
||||
- [ ] `getEntityJoin()` - 단건 조회 (findUnique)
|
||||
- [ ] `createEntityJoin()` - 생성 (create with validation)
|
||||
- [ ] `updateEntityJoin()` - 수정 (update)
|
||||
- [ ] `deleteEntityJoin()` - 삭제 (delete)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] 조인 유효성 검증 로직 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 조인 유효성 검증 테스트
|
||||
- [ ] 순환 참조 방지 테스트
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- LEFT JOIN 쿼리
|
||||
- 조인 유효성 검증
|
||||
- 순환 참조 방지
|
||||
- **예상 소요 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: LEFT JOIN, 조인 유효성 검증, 순환 참조 방지 포함
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
# 📋 Phase 3.14: AuthService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
AuthService는 **5개의 Prisma 호출**이 있으며, 사용자 인증 및 권한 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/authService.ts` |
|
||||
| 파일 크기 | 335 라인 |
|
||||
| Prisma 호출 | 0개 (이미 Phase 1.5에서 전환 완료) |
|
||||
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (보안, 암호화, 세션 관리) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.14) |
|
||||
| **상태** | ✅ **완료** (Phase 1.5에서 이미 완료) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 사용자 인증 기능 정상 동작
|
||||
- ⏳ 비밀번호 암호화/검증 유지
|
||||
- ⏳ 세션 관리 기능 유지
|
||||
- ⏳ 권한 검증 기능 유지
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (5개 예상)
|
||||
|
||||
#### 1. **사용자 로그인 (인증)**
|
||||
|
||||
- findFirst or findUnique
|
||||
- 이메일/사용자명으로 조회
|
||||
- 비밀번호 검증
|
||||
|
||||
#### 2. **사용자 정보 조회**
|
||||
|
||||
- findUnique
|
||||
- user_id 기준
|
||||
- 권한 정보 포함
|
||||
|
||||
#### 3. **사용자 생성 (회원가입)**
|
||||
|
||||
- create
|
||||
- 비밀번호 암호화
|
||||
- 중복 검사
|
||||
|
||||
#### 4. **비밀번호 변경**
|
||||
|
||||
- update
|
||||
- 기존 비밀번호 검증
|
||||
- 새 비밀번호 암호화
|
||||
|
||||
#### 5. **세션 관리**
|
||||
|
||||
- create, update, delete
|
||||
- 세션 토큰 저장/조회
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 인증 관련 전환 (2개)
|
||||
|
||||
- login() - 사용자 조회 + 비밀번호 검증
|
||||
- getUserInfo() - 사용자 정보 조회
|
||||
|
||||
### 2단계: 사용자 관리 전환 (2개)
|
||||
|
||||
- createUser() - 사용자 생성
|
||||
- changePassword() - 비밀번호 변경
|
||||
|
||||
### 3단계: 세션 관리 전환 (1개)
|
||||
|
||||
- manageSession() - 세션 CRUD
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 로그인 (비밀번호 검증)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async login(username: string, password: string) {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: username },
|
||||
{ email: username },
|
||||
],
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async login(username: string, password: string) {
|
||||
const user = await queryOne<any>(
|
||||
`SELECT * FROM users
|
||||
WHERE (username = $1 OR email = $1)
|
||||
AND is_active = $2`,
|
||||
[username, true]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 2: 사용자 생성 (비밀번호 암호화)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async createUser(userData: CreateUserDto) {
|
||||
// 중복 검사
|
||||
const existing = await prisma.users.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: userData.username },
|
||||
{ email: userData.email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("User already exists");
|
||||
}
|
||||
|
||||
// 비밀번호 암호화
|
||||
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// 사용자 생성
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password_hash: passwordHash,
|
||||
company_code: userData.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async createUser(userData: CreateUserDto) {
|
||||
// 중복 검사
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT * FROM users
|
||||
WHERE username = $1 OR email = $2`,
|
||||
[userData.username, userData.email]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
throw new Error("User already exists");
|
||||
}
|
||||
|
||||
// 비밀번호 암호화
|
||||
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// 사용자 생성
|
||||
const user = await queryOne<any>(
|
||||
`INSERT INTO users
|
||||
(username, email, password_hash, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[userData.username, userData.email, passwordHash, userData.company_code]
|
||||
);
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 3: 비밀번호 변경
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async changePassword(
|
||||
userId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isOldPasswordValid = await bcrypt.compare(
|
||||
oldPassword,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
throw new Error("Invalid old password");
|
||||
}
|
||||
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.users.update({
|
||||
where: { user_id: userId },
|
||||
data: { password_hash: newPasswordHash },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async changePassword(
|
||||
userId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
const user = await queryOne<any>(
|
||||
`SELECT * FROM users WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isOldPasswordValid = await bcrypt.compare(
|
||||
oldPassword,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
throw new Error("Invalid old password");
|
||||
}
|
||||
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET password_hash = $1, updated_at = NOW()
|
||||
WHERE user_id = $2`,
|
||||
[newPasswordHash, userId]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 비밀번호 보안
|
||||
|
||||
```typescript
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
// 비밀번호 해싱 (회원가입, 비밀번호 변경)
|
||||
const SALT_ROUNDS = 10;
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// 비밀번호 검증 (로그인)
|
||||
const isValid = await bcrypt.compare(plainPassword, passwordHash);
|
||||
```
|
||||
|
||||
### 2. SQL 인젝션 방지
|
||||
|
||||
```typescript
|
||||
// ❌ 위험: 직접 문자열 결합
|
||||
const sql = `SELECT * FROM users WHERE username = '${username}'`;
|
||||
|
||||
// ✅ 안전: 파라미터 바인딩
|
||||
const user = await queryOne(`SELECT * FROM users WHERE username = $1`, [
|
||||
username,
|
||||
]);
|
||||
```
|
||||
|
||||
### 3. 세션 토큰 관리
|
||||
|
||||
```typescript
|
||||
import crypto from "crypto";
|
||||
|
||||
// 세션 토큰 생성
|
||||
const sessionToken = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// 세션 저장
|
||||
await query(
|
||||
`INSERT INTO user_sessions (user_id, session_token, expires_at)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '1 day')`,
|
||||
[userId, sessionToken]
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 권한 검증
|
||||
|
||||
```typescript
|
||||
async checkPermission(userId: number, permission: string): Promise<boolean> {
|
||||
const result = await queryOne<{ has_permission: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM user_permissions up
|
||||
JOIN permissions p ON up.permission_id = p.permission_id
|
||||
WHERE up.user_id = $1 AND p.permission_name = $2
|
||||
) as has_permission`,
|
||||
[userId, permission]
|
||||
);
|
||||
|
||||
return result?.has_permission || false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (Phase 1.5에서 이미 완료됨)
|
||||
|
||||
AuthService는 Phase 1.5에서 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 전환된 Prisma 호출 (5개)
|
||||
|
||||
1. **`loginPwdCheck()`** - 로그인 비밀번호 검증
|
||||
|
||||
- user_info 테이블에서 비밀번호 조회
|
||||
- EncryptUtil을 활용한 비밀번호 검증
|
||||
- 마스터 패스워드 지원
|
||||
|
||||
2. **`insertLoginAccessLog()`** - 로그인 로그 기록
|
||||
|
||||
- login_access_log 테이블에 INSERT
|
||||
- 로그인 시간, IP 주소 등 기록
|
||||
|
||||
3. **`getUserInfo()`** - 사용자 정보 조회
|
||||
|
||||
- user_info 테이블 조회
|
||||
- PersonBean 객체로 반환
|
||||
|
||||
4. **`updateLastLoginDate()`** - 마지막 로그인 시간 업데이트
|
||||
|
||||
- user_info 테이블 UPDATE
|
||||
- last_login_date 갱신
|
||||
|
||||
5. **`checkUserPermission()`** - 사용자 권한 확인
|
||||
- user_auth 테이블 조회
|
||||
- 권한 코드 검증
|
||||
|
||||
### 주요 기술적 특징
|
||||
|
||||
- **보안**: EncryptUtil을 활용한 안전한 비밀번호 검증
|
||||
- **JWT 토큰**: JwtUtils를 활용한 토큰 생성 및 검증
|
||||
- **로깅**: 상세한 로그인 이력 기록
|
||||
- **에러 처리**: 안전한 에러 메시지 반환
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 보안 로직 유지
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ Phase 1.5에서 완료)
|
||||
|
||||
- [ ] `login()` - 사용자 조회 + 비밀번호 검증 (findFirst)
|
||||
- [ ] `getUserInfo()` - 사용자 정보 조회 (findUnique)
|
||||
- [ ] `createUser()` - 사용자 생성 (create with 중복 검사)
|
||||
- [ ] `changePassword()` - 비밀번호 변경 (findUnique + update)
|
||||
- [ ] `manageSession()` - 세션 관리 (create/update/delete)
|
||||
|
||||
### 2단계: 보안 검증
|
||||
|
||||
- [ ] 비밀번호 해싱 로직 유지 (bcrypt)
|
||||
- [ ] SQL 인젝션 방지 확인
|
||||
- [ ] 세션 토큰 보안 확인
|
||||
- [ ] 중복 계정 방지 확인
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 로그인 성공/실패 테스트
|
||||
- [ ] 사용자 생성 테스트
|
||||
- [ ] 비밀번호 변경 테스트
|
||||
- [ ] 세션 관리 테스트
|
||||
- [ ] 권한 검증 테스트
|
||||
- [ ] 보안 테스트
|
||||
- [ ] SQL 인젝션 테스트
|
||||
- [ ] 비밀번호 강도 테스트
|
||||
- [ ] 세션 탈취 방지 테스트
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] 보안 가이드 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||
- 보안 크리티컬 (비밀번호, 세션)
|
||||
- SQL 인젝션 방지 필수
|
||||
- 철저한 테스트 필요
|
||||
- **예상 소요 시간**: 1.5~2시간
|
||||
- Prisma 호출 전환: 40분
|
||||
- 보안 검증: 40분
|
||||
- 테스트: 40분
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 보안 필수 체크리스트
|
||||
|
||||
1. ✅ 모든 사용자 입력은 파라미터 바인딩 사용
|
||||
2. ✅ 비밀번호는 절대 평문 저장 금지 (bcrypt 사용)
|
||||
3. ✅ 세션 토큰은 충분히 길고 랜덤해야 함
|
||||
4. ✅ 비밀번호 실패 시 구체적 오류 메시지 금지 ("User not found" vs "Invalid credentials")
|
||||
5. ✅ 로그인 실패 횟수 제한 (Brute Force 방지)
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함
|
||||
**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수!
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
# 📋 Phase 3.15: Batch Services Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
배치 관련 서비스들은 총 **24개의 Prisma 호출**이 있으며, 배치 작업 실행 및 관리를 담당합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| 대상 서비스 | 4개 (BatchExternalDb, ExecutionLog, Management, Scheduler) |
|
||||
| 파일 위치 | `backend-node/src/services/batch*.ts` |
|
||||
| 총 파일 크기 | 2,161 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **24/24 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (외부 DB 연동, 스케줄링, 트랜잭션) |
|
||||
| 우선순위 | 🔴 높음 (Phase 3.15) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (24개)
|
||||
|
||||
#### 1. BatchExternalDbService (8개)
|
||||
|
||||
- `getAvailableConnections()` - findMany → query
|
||||
- `getTables()` - $queryRaw → query (information_schema)
|
||||
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||
- `getExternalTables()` - findUnique → queryOne (x5)
|
||||
|
||||
#### 2. BatchExecutionLogService (7개)
|
||||
|
||||
- `getExecutionLogs()` - findMany + count → query (JOIN + 동적 WHERE)
|
||||
- `createExecutionLog()` - create → queryOne (INSERT RETURNING)
|
||||
- `updateExecutionLog()` - update → queryOne (동적 UPDATE)
|
||||
- `deleteExecutionLog()` - delete → query
|
||||
- `getLatestExecutionLog()` - findFirst → queryOne
|
||||
- `getExecutionStats()` - findMany → query (동적 WHERE)
|
||||
|
||||
#### 3. BatchManagementService (5개)
|
||||
|
||||
- `getAvailableConnections()` - findMany → query
|
||||
- `getTables()` - $queryRaw → query (information_schema)
|
||||
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||
- `getExternalTables()` - findUnique → queryOne (x2)
|
||||
|
||||
#### 4. BatchSchedulerService (4개)
|
||||
|
||||
- `loadActiveBatchConfigs()` - findMany → query (JOIN with json_agg)
|
||||
- `updateBatchSchedule()` - findUnique → query (JOIN with json_agg)
|
||||
- `getDataFromSource()` - $queryRawUnsafe → query
|
||||
- `insertDataToTarget()` - $executeRawUnsafe → query
|
||||
|
||||
### 주요 기술적 해결 사항
|
||||
|
||||
1. **외부 DB 연결 조회 반복**
|
||||
|
||||
- 5개의 `findUnique` 호출을 `queryOne`으로 일괄 전환
|
||||
- 암호화/복호화 로직 유지
|
||||
|
||||
2. **배치 설정 + 매핑 JOIN**
|
||||
|
||||
- Prisma `include` → `json_agg` + `json_build_object`
|
||||
- `FILTER (WHERE bm.id IS NOT NULL)` 로 NULL 방지
|
||||
- 계층적 JSON 데이터 생성
|
||||
|
||||
3. **동적 WHERE 절 생성**
|
||||
|
||||
- 조건부 필터링 (batch_config_id, execution_status, 날짜 범위)
|
||||
- 파라미터 인덱스 동적 관리
|
||||
|
||||
4. **동적 UPDATE 쿼리**
|
||||
|
||||
- undefined 필드 제외
|
||||
- 8개 필드의 조건부 업데이트
|
||||
|
||||
5. **통계 쿼리 전환**
|
||||
- 클라이언트 사이드 집계 유지
|
||||
- 원본 데이터만 쿼리로 조회
|
||||
|
||||
### 컴파일 상태
|
||||
|
||||
✅ TypeScript 컴파일 성공
|
||||
✅ Linter 오류 없음
|
||||
|
||||
---
|
||||
|
||||
## 🔍 서비스별 상세 분석
|
||||
|
||||
### 1. BatchExternalDbService (8개 호출, 943 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 외부 DB에서 배치 데이터 조회
|
||||
- 외부 DB로 배치 데이터 저장
|
||||
- 외부 DB 연결 관리
|
||||
- 데이터 변환 및 매핑
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getExternalDbConnection()` - 외부 DB 연결 정보 조회
|
||||
- `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||
- `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||
- `validateExternalDbConnection()` - 연결 검증
|
||||
- `getExternalDbTables()` - 테이블 목록 조회
|
||||
- `getExternalDbColumns()` - 컬럼 정보 조회
|
||||
- `executeBatchQuery()` - 배치 쿼리 실행
|
||||
- `getBatchExecutionStatus()` - 실행 상태 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 다양한 DB 타입 지원 (PostgreSQL, MySQL, Oracle, MSSQL)
|
||||
- 연결 풀 관리
|
||||
- 트랜잭션 처리
|
||||
- 에러 핸들링 및 재시도
|
||||
|
||||
---
|
||||
|
||||
### 2. BatchExecutionLogService (7개 호출, 299 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 실행 로그 생성
|
||||
- 배치 실행 이력 조회
|
||||
- 배치 실행 통계
|
||||
- 로그 정리
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `createExecutionLog()` - 실행 로그 생성
|
||||
- `updateExecutionLog()` - 실행 로그 업데이트
|
||||
- `getExecutionLogs()` - 실행 로그 목록 조회
|
||||
- `getExecutionLogById()` - 실행 로그 단건 조회
|
||||
- `getExecutionStats()` - 실행 통계 조회
|
||||
- `cleanupOldLogs()` - 오래된 로그 삭제
|
||||
- `getFailedExecutions()` - 실패한 실행 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 대용량 로그 처리
|
||||
- 통계 쿼리 최적화
|
||||
- 로그 보관 정책
|
||||
- 페이징 및 필터링
|
||||
|
||||
---
|
||||
|
||||
### 3. BatchManagementService (5개 호출, 373 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 작업 설정 관리
|
||||
- 배치 작업 실행
|
||||
- 배치 작업 중지
|
||||
- 배치 작업 모니터링
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getBatchJobs()` - 배치 작업 목록 조회
|
||||
- `getBatchJob()` - 배치 작업 단건 조회
|
||||
- `createBatchJob()` - 배치 작업 생성
|
||||
- `updateBatchJob()` - 배치 작업 수정
|
||||
- `deleteBatchJob()` - 배치 작업 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 설정 필드 (job_config)
|
||||
- 작업 상태 관리
|
||||
- 동시 실행 제어
|
||||
- 의존성 관리
|
||||
|
||||
---
|
||||
|
||||
### 4. BatchSchedulerService (4개 호출, 546 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 스케줄 설정
|
||||
- Cron 표현식 관리
|
||||
- 스케줄 실행
|
||||
- 다음 실행 시간 계산
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getScheduledBatches()` - 스케줄된 배치 조회
|
||||
- `createSchedule()` - 스케줄 생성
|
||||
- `updateSchedule()` - 스케줄 수정
|
||||
- `deleteSchedule()` - 스케줄 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- Cron 표현식 파싱
|
||||
- 시간대 처리
|
||||
- 실행 이력 추적
|
||||
- 스케줄 충돌 방지
|
||||
|
||||
---
|
||||
|
||||
## 💡 통합 전환 전략
|
||||
|
||||
### Phase 1: 핵심 서비스 전환 (12개)
|
||||
|
||||
**BatchManagementService (5개) + BatchExecutionLogService (7개)**
|
||||
|
||||
- 배치 관리 및 로깅 기능 우선
|
||||
- 상대적으로 단순한 CRUD
|
||||
|
||||
### Phase 2: 스케줄러 전환 (4개)
|
||||
|
||||
**BatchSchedulerService (4개)**
|
||||
|
||||
- 스케줄 관리
|
||||
- Cron 표현식 처리
|
||||
|
||||
### Phase 3: 외부 DB 연동 전환 (8개)
|
||||
|
||||
**BatchExternalDbService (8개)**
|
||||
|
||||
- 가장 복잡한 서비스
|
||||
- 외부 DB 연결 및 쿼리
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 배치 실행 로그 생성
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const log = await prisma.batch_execution_logs.create({
|
||||
data: {
|
||||
batch_id: batchId,
|
||||
status: "running",
|
||||
started_at: new Date(),
|
||||
execution_params: params,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const log = await queryOne<any>(
|
||||
`INSERT INTO batch_execution_logs
|
||||
(batch_id, status, started_at, execution_params, company_code)
|
||||
VALUES ($1, $2, NOW(), $3, $4)
|
||||
RETURNING *`,
|
||||
[batchId, "running", JSON.stringify(params), companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 배치 통계 조회
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const stats = await prisma.batch_execution_logs.groupBy({
|
||||
by: ["status"],
|
||||
where: {
|
||||
batch_id: batchId,
|
||||
started_at: { gte: startDate, lte: endDate },
|
||||
},
|
||||
_count: { id: true },
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const stats = await query<{ status: string; count: string }>(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_id = $1
|
||||
AND started_at >= $2
|
||||
AND started_at <= $3
|
||||
GROUP BY status`,
|
||||
[batchId, startDate, endDate]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 외부 DB 연결 및 쿼리
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId },
|
||||
});
|
||||
|
||||
// 외부 DB 쿼리 실행 (Prisma 사용 불가, 이미 Raw Query일 가능성)
|
||||
const externalData = await externalDbClient.query(sql);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
// 외부 DB 쿼리 실행 (기존 로직 유지)
|
||||
const externalData = await externalDbClient.query(sql);
|
||||
```
|
||||
|
||||
### 예시 4: 스케줄 관리
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const schedule = await prisma.batch_schedules.create({
|
||||
data: {
|
||||
batch_id: batchId,
|
||||
cron_expression: cronExp,
|
||||
is_active: true,
|
||||
next_run_at: calculateNextRun(cronExp),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const nextRun = calculateNextRun(cronExp);
|
||||
|
||||
const schedule = await queryOne<any>(
|
||||
`INSERT INTO batch_schedules
|
||||
(batch_id, cron_expression, is_active, next_run_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[batchId, cronExp, true, nextRun]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 외부 DB 연결 관리
|
||||
|
||||
```typescript
|
||||
import { DatabaseConnectorFactory } from "../database/connectorFactory";
|
||||
|
||||
// 외부 DB 연결 생성
|
||||
const connector = DatabaseConnectorFactory.create(connection);
|
||||
const externalClient = await connector.connect();
|
||||
|
||||
try {
|
||||
// 쿼리 실행
|
||||
const result = await externalClient.query(sql, params);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 트랜잭션 처리
|
||||
|
||||
```typescript
|
||||
await transaction(async (client) => {
|
||||
// 배치 상태 업데이트
|
||||
await client.query(`UPDATE batch_jobs SET status = $1 WHERE id = $2`, [
|
||||
"running",
|
||||
batchId,
|
||||
]);
|
||||
|
||||
// 실행 로그 생성
|
||||
await client.query(
|
||||
`INSERT INTO batch_execution_logs (batch_id, status, started_at)
|
||||
VALUES ($1, $2, NOW())`,
|
||||
[batchId, "running"]
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Cron 표현식 처리
|
||||
|
||||
```typescript
|
||||
import cron from "node-cron";
|
||||
|
||||
// Cron 표현식 검증
|
||||
const isValid = cron.validate(cronExpression);
|
||||
|
||||
// 다음 실행 시간 계산
|
||||
function calculateNextRun(cronExp: string): Date {
|
||||
// Cron 파서를 사용하여 다음 실행 시간 계산
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 대용량 데이터 처리
|
||||
|
||||
```typescript
|
||||
// 스트리밍 방식으로 대용량 데이터 처리
|
||||
const stream = await query<any>(
|
||||
`SELECT * FROM large_table WHERE batch_id = $1`,
|
||||
[batchId]
|
||||
);
|
||||
|
||||
for await (const row of stream) {
|
||||
// 행 단위 처리
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 체크리스트
|
||||
|
||||
### BatchExternalDbService (8개)
|
||||
|
||||
- [ ] `getExternalDbConnection()` - 연결 정보 조회
|
||||
- [ ] `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||
- [ ] `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||
- [ ] `validateExternalDbConnection()` - 연결 검증
|
||||
- [ ] `getExternalDbTables()` - 테이블 목록 조회
|
||||
- [ ] `getExternalDbColumns()` - 컬럼 정보 조회
|
||||
- [ ] `executeBatchQuery()` - 배치 쿼리 실행
|
||||
- [ ] `getBatchExecutionStatus()` - 실행 상태 조회
|
||||
|
||||
### BatchExecutionLogService (7개)
|
||||
|
||||
- [ ] `createExecutionLog()` - 실행 로그 생성
|
||||
- [ ] `updateExecutionLog()` - 실행 로그 업데이트
|
||||
- [ ] `getExecutionLogs()` - 실행 로그 목록 조회
|
||||
- [ ] `getExecutionLogById()` - 실행 로그 단건 조회
|
||||
- [ ] `getExecutionStats()` - 실행 통계 조회
|
||||
- [ ] `cleanupOldLogs()` - 오래된 로그 삭제
|
||||
- [ ] `getFailedExecutions()` - 실패한 실행 조회
|
||||
|
||||
### BatchManagementService (5개)
|
||||
|
||||
- [ ] `getBatchJobs()` - 배치 작업 목록 조회
|
||||
- [ ] `getBatchJob()` - 배치 작업 단건 조회
|
||||
- [ ] `createBatchJob()` - 배치 작업 생성
|
||||
- [ ] `updateBatchJob()` - 배치 작업 수정
|
||||
- [ ] `deleteBatchJob()` - 배치 작업 삭제
|
||||
|
||||
### BatchSchedulerService (4개)
|
||||
|
||||
- [ ] `getScheduledBatches()` - 스케줄된 배치 조회
|
||||
- [ ] `createSchedule()` - 스케줄 생성
|
||||
- [ ] `updateSchedule()` - 스케줄 수정
|
||||
- [ ] `deleteSchedule()` - 스케줄 삭제
|
||||
|
||||
### 공통 작업
|
||||
|
||||
- [ ] import 문 수정 (모든 서비스)
|
||||
- [ ] Prisma import 완전 제거 (모든 서비스)
|
||||
- [ ] 트랜잭션 로직 확인
|
||||
- [ ] 에러 핸들링 검증
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (24개)
|
||||
|
||||
- 각 Prisma 호출별 1개씩
|
||||
|
||||
### 통합 테스트 (8개)
|
||||
|
||||
- BatchExternalDbService: 외부 DB 연동 테스트 (2개)
|
||||
- BatchExecutionLogService: 로그 생성 및 조회 테스트 (2개)
|
||||
- BatchManagementService: 배치 작업 실행 테스트 (2개)
|
||||
- BatchSchedulerService: 스케줄 실행 테스트 (2개)
|
||||
|
||||
### 성능 테스트
|
||||
|
||||
- 대용량 데이터 처리 성능
|
||||
- 동시 배치 실행 성능
|
||||
- 외부 DB 연결 풀 성능
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐⭐ (매우 높음)
|
||||
- 외부 DB 연동
|
||||
- 트랜잭션 처리
|
||||
- 스케줄링 로직
|
||||
- 대용량 데이터 처리
|
||||
- **예상 소요 시간**: 4~5시간
|
||||
- Phase 1 (BatchManagement + ExecutionLog): 1.5시간
|
||||
- Phase 2 (Scheduler): 1시간
|
||||
- Phase 3 (ExternalDb): 2시간
|
||||
- 테스트 및 문서화: 0.5시간
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 중요 체크포인트
|
||||
|
||||
1. ✅ 외부 DB 연결은 반드시 try-finally에서 해제
|
||||
2. ✅ 배치 실행 중 에러 시 롤백 처리
|
||||
3. ✅ Cron 표현식 검증 필수
|
||||
4. ✅ 대용량 데이터는 스트리밍 방식 사용
|
||||
5. ✅ 동시 실행 제한 확인
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 연결 풀 활용
|
||||
- 배치 쿼리 최적화
|
||||
- 인덱스 확인
|
||||
- 불필요한 로그 제거
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 외부 DB 연동, 스케줄링, 트랜잭션 처리 포함
|
||||
**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수!
|
||||
|
|
@ -1,540 +0,0 @@
|
|||
# 📋 Phase 3.16: Data Management Services Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
데이터 관리 관련 서비스들은 총 **18개의 Prisma 호출**이 있으며, 동적 폼, 데이터 매핑, 데이터 서비스, 관리자 기능을 담당합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 대상 서비스 | 4개 (EnhancedDynamicForm, DataMapping, Data, Admin) |
|
||||
| 파일 위치 | `backend-node/src/services/{enhanced,data,admin}*.ts` |
|
||||
| 총 파일 크기 | 2,062 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **18/18 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (동적 쿼리, JSON 필드, 관리자 기능) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.16) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (18개)
|
||||
|
||||
#### 1. EnhancedDynamicFormService (6개)
|
||||
|
||||
- `validateTableExists()` - $queryRawUnsafe → query
|
||||
- `getTableColumns()` - $queryRawUnsafe → query
|
||||
- `getColumnWebTypes()` - $queryRawUnsafe → query
|
||||
- `getPrimaryKeys()` - $queryRawUnsafe → query
|
||||
- `performInsert()` - $queryRawUnsafe → query
|
||||
- `performUpdate()` - $queryRawUnsafe → query
|
||||
|
||||
#### 2. DataMappingService (5개)
|
||||
|
||||
- `getSourceData()` - $queryRawUnsafe → query
|
||||
- `executeInsert()` - $executeRawUnsafe → query
|
||||
- `executeUpsert()` - $executeRawUnsafe → query
|
||||
- `executeUpdate()` - $executeRawUnsafe → query
|
||||
- `disconnect()` - 제거 (Raw Query는 disconnect 불필요)
|
||||
|
||||
#### 3. DataService (4개)
|
||||
|
||||
- `getTableData()` - $queryRawUnsafe → query
|
||||
- `checkTableExists()` - $queryRawUnsafe → query
|
||||
- `getTableColumnsSimple()` - $queryRawUnsafe → query
|
||||
- `getColumnLabel()` - $queryRawUnsafe → query
|
||||
|
||||
#### 4. AdminService (3개)
|
||||
|
||||
- `getAdminMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||
- `getUserMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||
- `getMenuInfo()` - findUnique → query (JOIN)
|
||||
|
||||
### 주요 기술적 해결 사항
|
||||
|
||||
1. **변수명 충돌 해결**
|
||||
|
||||
- `dataService.ts`에서 `query` 변수 → `sql` 변수로 변경
|
||||
- `query()` 함수와 로컬 변수 충돌 방지
|
||||
|
||||
2. **WITH RECURSIVE 쿼리 전환**
|
||||
|
||||
- Prisma의 `$queryRaw` 템플릿 리터럴 → 일반 문자열
|
||||
- `${userLang}` → `$1` 파라미터 바인딩
|
||||
|
||||
3. **JOIN 쿼리 전환**
|
||||
|
||||
- Prisma의 `include` 옵션 → `LEFT JOIN` 쿼리
|
||||
- 관계 데이터를 단일 쿼리로 조회
|
||||
|
||||
4. **동적 쿼리 생성**
|
||||
- 동적 WHERE 조건 구성
|
||||
- SQL 인젝션 방지 (컬럼명 검증)
|
||||
- 동적 ORDER BY 처리
|
||||
|
||||
### 컴파일 상태
|
||||
|
||||
✅ TypeScript 컴파일 성공
|
||||
✅ Linter 오류 없음
|
||||
|
||||
---
|
||||
|
||||
## 🔍 서비스별 상세 분석
|
||||
|
||||
### 1. EnhancedDynamicFormService (6개 호출, 786 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 고급 동적 폼 관리
|
||||
- 폼 검증 규칙
|
||||
- 조건부 필드 표시
|
||||
- 폼 템플릿 관리
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getEnhancedForms()` - 고급 폼 목록 조회
|
||||
- `getEnhancedForm()` - 고급 폼 단건 조회
|
||||
- `createEnhancedForm()` - 고급 폼 생성
|
||||
- `updateEnhancedForm()` - 고급 폼 수정
|
||||
- `deleteEnhancedForm()` - 고급 폼 삭제
|
||||
- `getFormValidationRules()` - 검증 규칙 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 필드 (validation_rules, conditional_logic, field_config)
|
||||
- 복잡한 검증 규칙
|
||||
- 동적 필드 생성
|
||||
- 조건부 표시 로직
|
||||
|
||||
---
|
||||
|
||||
### 2. DataMappingService (5개 호출, 575 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 데이터 매핑 설정 관리
|
||||
- 소스-타겟 필드 매핑
|
||||
- 데이터 변환 규칙
|
||||
- 매핑 실행
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getDataMappings()` - 매핑 설정 목록 조회
|
||||
- `getDataMapping()` - 매핑 설정 단건 조회
|
||||
- `createDataMapping()` - 매핑 설정 생성
|
||||
- `updateDataMapping()` - 매핑 설정 수정
|
||||
- `deleteDataMapping()` - 매핑 설정 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 필드 (field_mappings, transformation_rules)
|
||||
- 복잡한 변환 로직
|
||||
- 매핑 검증
|
||||
- 실행 이력 추적
|
||||
|
||||
---
|
||||
|
||||
### 3. DataService (4개 호출, 327 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 동적 데이터 조회
|
||||
- 데이터 필터링
|
||||
- 데이터 정렬
|
||||
- 데이터 집계
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getDataByTable()` - 테이블별 데이터 조회
|
||||
- `getDataById()` - 데이터 단건 조회
|
||||
- `executeCustomQuery()` - 커스텀 쿼리 실행
|
||||
- `getDataStatistics()` - 데이터 통계 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 동적 테이블 쿼리
|
||||
- SQL 인젝션 방지
|
||||
- 동적 WHERE 조건
|
||||
- 집계 쿼리
|
||||
|
||||
---
|
||||
|
||||
### 4. AdminService (3개 호출, 374 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 관리자 메뉴 관리
|
||||
- 시스템 설정
|
||||
- 사용자 관리
|
||||
- 로그 조회
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getAdminMenus()` - 관리자 메뉴 조회
|
||||
- `getSystemSettings()` - 시스템 설정 조회
|
||||
- `updateSystemSettings()` - 시스템 설정 업데이트
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 메뉴 계층 구조
|
||||
- 권한 기반 필터링
|
||||
- JSON 설정 필드
|
||||
- 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 💡 통합 전환 전략
|
||||
|
||||
### Phase 1: 단순 CRUD 전환 (12개)
|
||||
|
||||
**EnhancedDynamicFormService (6개) + DataMappingService (5개) + AdminService (1개)**
|
||||
|
||||
- 기본 CRUD 기능
|
||||
- JSON 필드 처리
|
||||
|
||||
### Phase 2: 동적 쿼리 전환 (4개)
|
||||
|
||||
**DataService (4개)**
|
||||
|
||||
- 동적 테이블 쿼리
|
||||
- 보안 검증
|
||||
|
||||
### Phase 3: 고급 기능 전환 (2개)
|
||||
|
||||
**AdminService (2개)**
|
||||
|
||||
- 시스템 설정
|
||||
- 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 고급 폼 생성 (JSON 필드)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const form = await prisma.enhanced_forms.create({
|
||||
data: {
|
||||
form_code: formCode,
|
||||
form_name: formName,
|
||||
validation_rules: validationRules, // JSON
|
||||
conditional_logic: conditionalLogic, // JSON
|
||||
field_config: fieldConfig, // JSON
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const form = await queryOne<any>(
|
||||
`INSERT INTO enhanced_forms
|
||||
(form_code, form_name, validation_rules, conditional_logic,
|
||||
field_config, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
formCode,
|
||||
formName,
|
||||
JSON.stringify(validationRules),
|
||||
JSON.stringify(conditionalLogic),
|
||||
JSON.stringify(fieldConfig),
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 데이터 매핑 조회
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const mappings = await prisma.data_mappings.findMany({
|
||||
where: {
|
||||
source_table: sourceTable,
|
||||
target_table: targetTable,
|
||||
is_active: true,
|
||||
},
|
||||
include: {
|
||||
source_columns: true,
|
||||
target_columns: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
dm.*,
|
||||
json_agg(DISTINCT jsonb_build_object(
|
||||
'column_id', sc.column_id,
|
||||
'column_name', sc.column_name
|
||||
)) FILTER (WHERE sc.column_id IS NOT NULL) as source_columns,
|
||||
json_agg(DISTINCT jsonb_build_object(
|
||||
'column_id', tc.column_id,
|
||||
'column_name', tc.column_name
|
||||
)) FILTER (WHERE tc.column_id IS NOT NULL) as target_columns
|
||||
FROM data_mappings dm
|
||||
LEFT JOIN columns sc ON dm.mapping_id = sc.mapping_id AND sc.type = 'source'
|
||||
LEFT JOIN columns tc ON dm.mapping_id = tc.mapping_id AND tc.type = 'target'
|
||||
WHERE dm.source_table = $1
|
||||
AND dm.target_table = $2
|
||||
AND dm.is_active = $3
|
||||
GROUP BY dm.mapping_id`,
|
||||
[sourceTable, targetTable, true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 테이블 쿼리 (DataService)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// Prisma로는 동적 테이블 쿼리 불가능
|
||||
// 이미 $queryRawUnsafe 사용 중일 가능성
|
||||
const data = await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ${tableName} WHERE ${whereClause}`,
|
||||
...params
|
||||
);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// SQL 인젝션 방지를 위한 테이블명 검증
|
||||
const validTableName = validateTableName(tableName);
|
||||
|
||||
const data = await query<any>(
|
||||
`SELECT * FROM ${validTableName} WHERE ${whereClause}`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 관리자 메뉴 조회 (계층 구조)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const menus = await prisma.admin_menus.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: { sort_order: "asc" },
|
||||
include: {
|
||||
children: {
|
||||
orderBy: { sort_order: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 재귀 CTE를 사용한 계층 쿼리
|
||||
const menus = await query<any>(
|
||||
`WITH RECURSIVE menu_tree AS (
|
||||
SELECT *, 0 as level, ARRAY[menu_id] as path
|
||||
FROM admin_menus
|
||||
WHERE parent_id IS NULL AND is_active = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.*, mt.level + 1, mt.path || m.menu_id
|
||||
FROM admin_menus m
|
||||
JOIN menu_tree mt ON m.parent_id = mt.menu_id
|
||||
WHERE m.is_active = $1
|
||||
)
|
||||
SELECT * FROM menu_tree
|
||||
ORDER BY path, sort_order`,
|
||||
[true]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
```typescript
|
||||
// 복잡한 JSON 구조
|
||||
interface ValidationRules {
|
||||
required?: string[];
|
||||
min?: Record<string, number>;
|
||||
max?: Record<string, number>;
|
||||
pattern?: Record<string, string>;
|
||||
custom?: Array<{ field: string; rule: string }>;
|
||||
}
|
||||
|
||||
// 저장 시
|
||||
JSON.stringify(validationRules);
|
||||
|
||||
// 조회 후
|
||||
const parsed =
|
||||
typeof row.validation_rules === "string"
|
||||
? JSON.parse(row.validation_rules)
|
||||
: row.validation_rules;
|
||||
```
|
||||
|
||||
### 2. 동적 테이블 쿼리 보안
|
||||
|
||||
```typescript
|
||||
// 테이블명 화이트리스트
|
||||
const ALLOWED_TABLES = ["users", "products", "orders"];
|
||||
|
||||
function validateTableName(tableName: string): string {
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
throw new Error("Invalid table name");
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
// 컬럼명 검증
|
||||
function validateColumnName(columnName: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(columnName)) {
|
||||
throw new Error("Invalid column name");
|
||||
}
|
||||
return columnName;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 재귀 CTE (계층 구조)
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE hierarchy AS (
|
||||
-- 최상위 노드
|
||||
SELECT * FROM table WHERE parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 하위 노드
|
||||
SELECT t.* FROM table t
|
||||
JOIN hierarchy h ON t.parent_id = h.id
|
||||
)
|
||||
SELECT * FROM hierarchy
|
||||
```
|
||||
|
||||
### 4. JSON 집계 (관계 데이터)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
parent.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
jsonb_build_object('id', child.id, 'name', child.name)
|
||||
) FILTER (WHERE child.id IS NOT NULL),
|
||||
'[]'
|
||||
) as children
|
||||
FROM parent
|
||||
LEFT JOIN child ON parent.id = child.parent_id
|
||||
GROUP BY parent.id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 체크리스트
|
||||
|
||||
### EnhancedDynamicFormService (6개)
|
||||
|
||||
- [ ] `getEnhancedForms()` - 목록 조회
|
||||
- [ ] `getEnhancedForm()` - 단건 조회
|
||||
- [ ] `createEnhancedForm()` - 생성 (JSON 필드)
|
||||
- [ ] `updateEnhancedForm()` - 수정 (JSON 필드)
|
||||
- [ ] `deleteEnhancedForm()` - 삭제
|
||||
- [ ] `getFormValidationRules()` - 검증 규칙 조회
|
||||
|
||||
### DataMappingService (5개)
|
||||
|
||||
- [ ] `getDataMappings()` - 목록 조회
|
||||
- [ ] `getDataMapping()` - 단건 조회
|
||||
- [ ] `createDataMapping()` - 생성
|
||||
- [ ] `updateDataMapping()` - 수정
|
||||
- [ ] `deleteDataMapping()` - 삭제
|
||||
|
||||
### DataService (4개)
|
||||
|
||||
- [ ] `getDataByTable()` - 동적 테이블 조회
|
||||
- [ ] `getDataById()` - 단건 조회
|
||||
- [ ] `executeCustomQuery()` - 커스텀 쿼리
|
||||
- [ ] `getDataStatistics()` - 통계 조회
|
||||
|
||||
### AdminService (3개)
|
||||
|
||||
- [ ] `getAdminMenus()` - 메뉴 조회 (재귀 CTE)
|
||||
- [ ] `getSystemSettings()` - 시스템 설정 조회
|
||||
- [ ] `updateSystemSettings()` - 시스템 설정 업데이트
|
||||
|
||||
### 공통 작업
|
||||
|
||||
- [ ] import 문 수정 (모든 서비스)
|
||||
- [ ] Prisma import 완전 제거
|
||||
- [ ] JSON 필드 처리 확인
|
||||
- [ ] 보안 검증 (SQL 인젝션)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (18개)
|
||||
|
||||
- 각 Prisma 호출별 1개씩
|
||||
|
||||
### 통합 테스트 (6개)
|
||||
|
||||
- EnhancedDynamicFormService: 폼 생성 및 검증 테스트 (2개)
|
||||
- DataMappingService: 매핑 설정 및 실행 테스트 (2개)
|
||||
- DataService: 동적 쿼리 및 보안 테스트 (1개)
|
||||
- AdminService: 메뉴 계층 구조 테스트 (1개)
|
||||
|
||||
### 보안 테스트
|
||||
|
||||
- SQL 인젝션 방지 테스트
|
||||
- 테이블명 검증 테스트
|
||||
- 컬럼명 검증 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||
- JSON 필드 처리
|
||||
- 동적 쿼리 보안
|
||||
- 재귀 CTE
|
||||
- JSON 집계
|
||||
- **예상 소요 시간**: 2.5~3시간
|
||||
- Phase 1 (기본 CRUD): 1시간
|
||||
- Phase 2 (동적 쿼리): 1시간
|
||||
- Phase 3 (고급 기능): 0.5시간
|
||||
- 테스트 및 문서화: 0.5시간
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 보안 필수 체크리스트
|
||||
|
||||
1. ✅ 동적 테이블명은 반드시 화이트리스트 검증
|
||||
2. ✅ 동적 컬럼명은 정규식으로 검증
|
||||
3. ✅ WHERE 절 파라미터는 반드시 바인딩
|
||||
4. ✅ JSON 필드는 파싱 에러 처리
|
||||
5. ✅ 재귀 쿼리는 깊이 제한 설정
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- JSON 필드 인덱싱 (GIN 인덱스)
|
||||
- 재귀 쿼리 깊이 제한
|
||||
- 집계 쿼리 최적화
|
||||
- 필요시 캐싱 적용
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드, 동적 쿼리, 재귀 CTE, 보안 검증 포함
|
||||
**⚠️ 주의**: 동적 쿼리는 SQL 인젝션 방지가 매우 중요!
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# 📋 Phase 3.17: ReferenceCacheService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ReferenceCacheService는 **0개의 Prisma 호출**이 있으며, 참조 데이터 캐싱을 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/referenceCacheService.ts` |
|
||||
| 파일 크기 | 499 라인 |
|
||||
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||
| **현재 진행률** | **3/3 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 낮음 (캐싱 로직) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 3.17) |
|
||||
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||
|
||||
ReferenceCacheService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **참조 데이터 캐싱**
|
||||
|
||||
- 자주 사용되는 참조 테이블 데이터를 메모리에 캐싱
|
||||
- 성능 향상을 위한 캐시 전략
|
||||
|
||||
2. **캐시 관리**
|
||||
|
||||
- 캐시 갱신 로직
|
||||
- TTL(Time To Live) 관리
|
||||
- 캐시 무효화
|
||||
|
||||
3. **데이터 조회 최적화**
|
||||
- 캐시 히트/미스 처리
|
||||
- 백그라운드 갱신
|
||||
|
||||
### 기술적 특징
|
||||
|
||||
- **메모리 캐싱**: Map/Object 기반 인메모리 캐싱
|
||||
- **성능 최적화**: 반복 DB 조회 최소화
|
||||
- **자동 갱신**: 주기적 캐시 갱신 로직
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 캐싱 로직 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 📝 비고
|
||||
|
||||
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||
|
||||
**상태**: ✅ **완료**
|
||||
**특이사항**: 캐싱 로직으로 성능에 중요한 서비스
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# 📋 Phase 3.18: DDLExecutionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLExecutionService는 **0개의 Prisma 호출**이 있으며, DDL 실행 및 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||
| 파일 크기 | 786 라인 |
|
||||
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (DDL 실행, 안전성 검증) |
|
||||
| 우선순위 | 🔴 높음 (Phase 3.18) |
|
||||
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||
|
||||
DDLExecutionService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **테이블 생성 (CREATE TABLE)**
|
||||
|
||||
- 동적 테이블 생성
|
||||
- 컬럼 정의 및 제약조건
|
||||
- 인덱스 생성
|
||||
|
||||
2. **컬럼 추가 (ADD COLUMN)**
|
||||
|
||||
- 기존 테이블에 컬럼 추가
|
||||
- 데이터 타입 검증
|
||||
- 기본값 설정
|
||||
|
||||
3. **테이블/컬럼 삭제 (DROP)**
|
||||
|
||||
- 안전한 삭제 검증
|
||||
- 의존성 체크
|
||||
- 롤백 가능성
|
||||
|
||||
4. **DDL 안전성 검증**
|
||||
|
||||
- DDL 실행 전 검증
|
||||
- 순환 참조 방지
|
||||
- 데이터 손실 방지
|
||||
|
||||
5. **DDL 실행 이력**
|
||||
|
||||
- 모든 DDL 실행 기록
|
||||
- 성공/실패 로그
|
||||
- 롤백 정보
|
||||
|
||||
6. **트랜잭션 관리**
|
||||
- DDL 트랜잭션 처리
|
||||
- 에러 시 롤백
|
||||
- 일관성 유지
|
||||
|
||||
### 기술적 특징
|
||||
|
||||
- **동적 DDL 생성**: 파라미터 기반 DDL 쿼리 생성
|
||||
- **안전성 검증**: 실행 전 다중 검증 단계
|
||||
- **감사 로깅**: DDLAuditLogger와 연동
|
||||
- **PostgreSQL 특화**: PostgreSQL DDL 문법 활용
|
||||
|
||||
### 보안 및 안전성
|
||||
|
||||
- **SQL 인젝션 방지**: 테이블/컬럼명 화이트리스트 검증
|
||||
- **권한 검증**: 사용자 권한 확인
|
||||
- **백업 권장**: DDL 실행 전 백업 체크
|
||||
- **복구 가능성**: 실행 이력 기록
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 안전성 검증 로직 유지
|
||||
- [x] DDLAuditLogger 연동
|
||||
|
||||
---
|
||||
|
||||
## 📝 비고
|
||||
|
||||
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||
|
||||
**상태**: ✅ **완료**
|
||||
**특이사항**: DDL 실행의 핵심 서비스로 안전성이 매우 중요
|
||||
**⚠️ 주의**: 프로덕션 환경에서 DDL 실행 시 각별한 주의 필요
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
# 🎨 Phase 3.7: LayoutService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
LayoutService는 **10개의 Prisma 호출**이 있으며, 레이아웃 표준 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/layoutService.ts` |
|
||||
| 파일 크기 | 425+ 라인 |
|
||||
| Prisma 호출 | 10개 |
|
||||
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||
| 복잡도 | 중간 (JSON 필드, 검색, 통계) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.7) |
|
||||
| **상태** | ⏳ **대기 중** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ JSON 필드 처리 (layout_config, sections)
|
||||
- ⏳ 복잡한 검색 조건 처리
|
||||
- ⏳ GROUP BY 통계 쿼리 전환
|
||||
- ⏳ 모든 단위 테스트 통과
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (10개)
|
||||
|
||||
#### 1. **getLayouts()** - 레이아웃 목록 조회
|
||||
```typescript
|
||||
// Line 92, 102
|
||||
const total = await prisma.layout_standards.count({ where });
|
||||
const layouts = await prisma.layout_standards.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: size,
|
||||
orderBy: { updated_date: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **getLayoutByCode()** - 레이아웃 단건 조회
|
||||
```typescript
|
||||
// Line 152
|
||||
const layout = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **createLayout()** - 레이아웃 생성
|
||||
```typescript
|
||||
// Line 199
|
||||
const layout = await prisma.layout_standards.create({
|
||||
data: {
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_type,
|
||||
category,
|
||||
layout_config: safeJSONStringify(layout_config),
|
||||
sections: safeJSONStringify(sections),
|
||||
// ... 기타 필드
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **updateLayout()** - 레이아웃 수정
|
||||
```typescript
|
||||
// Line 230, 267
|
||||
const existing = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
|
||||
const updated = await prisma.layout_standards.update({
|
||||
where: { id: existing.id },
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **deleteLayout()** - 레이아웃 삭제
|
||||
```typescript
|
||||
// Line 283, 295
|
||||
const existing = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
|
||||
await prisma.layout_standards.update({
|
||||
where: { id: existing.id },
|
||||
data: { is_active: "N", updated_by, updated_date: new Date() },
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. **getLayoutStatistics()** - 레이아웃 통계
|
||||
```typescript
|
||||
// Line 345
|
||||
const counts = await prisma.layout_standards.groupBy({
|
||||
by: ["category", "layout_type"],
|
||||
where: { company_code: companyCode, is_active: "Y" },
|
||||
_count: { id: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 7. **getLayoutCategories()** - 카테고리 목록
|
||||
```typescript
|
||||
// Line 373
|
||||
const existingCodes = await prisma.layout_standards.findMany({
|
||||
where: { company_code: companyCode },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getLayouts()` - 목록 조회 (count + findMany)
|
||||
- `getLayoutByCode()` - 단건 조회 (findFirst)
|
||||
- `createLayout()` - 생성 (create)
|
||||
- `updateLayout()` - 수정 (findFirst + update)
|
||||
- `deleteLayout()` - 삭제 (findFirst + update - soft delete)
|
||||
|
||||
### 2단계: 통계 및 집계 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getLayoutStatistics()` - 통계 (groupBy)
|
||||
- `getLayoutCategories()` - 카테고리 목록 (findMany + distinct)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 레이아웃 목록 조회 (동적 WHERE + 페이지네이션)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const where: any = { company_code: companyCode };
|
||||
if (category) where.category = category;
|
||||
if (layoutType) where.layout_type = layoutType;
|
||||
if (searchTerm) {
|
||||
where.OR = [
|
||||
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ layout_code: { contains: searchTerm, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const total = await prisma.layout_standards.count({ where });
|
||||
const layouts = await prisma.layout_standards.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: size,
|
||||
orderBy: { updated_date: "desc" },
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
const whereConditions: string[] = ["company_code = $1"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (category) {
|
||||
whereConditions.push(`category = $${paramIndex++}`);
|
||||
values.push(category);
|
||||
}
|
||||
if (layoutType) {
|
||||
whereConditions.push(`layout_type = $${paramIndex++}`);
|
||||
values.push(layoutType);
|
||||
}
|
||||
if (searchTerm) {
|
||||
whereConditions.push(
|
||||
`(layout_name ILIKE $${paramIndex} OR layout_code ILIKE $${paramIndex})`
|
||||
);
|
||||
values.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||
|
||||
// 총 개수 조회
|
||||
const countResult = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
|
||||
values
|
||||
);
|
||||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
// 데이터 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT * FROM layout_standards
|
||||
${whereClause}
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...values, size, skip]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: JSON 필드 처리 (레이아웃 생성)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const layout = await prisma.layout_standards.create({
|
||||
data: {
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_config: safeJSONStringify(layout_config), // JSON 필드
|
||||
sections: safeJSONStringify(sections), // JSON 필드
|
||||
company_code: companyCode,
|
||||
created_by: createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const layout = await queryOne<any>(
|
||||
`INSERT INTO layout_standards
|
||||
(layout_code, layout_name, layout_type, category, layout_config, sections,
|
||||
company_code, is_active, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_type,
|
||||
category,
|
||||
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
|
||||
safeJSONStringify(sections),
|
||||
companyCode,
|
||||
"Y",
|
||||
createdBy,
|
||||
updatedBy,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: GROUP BY 통계 쿼리
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const counts = await prisma.layout_standards.groupBy({
|
||||
by: ["category", "layout_type"],
|
||||
where: { company_code: companyCode, is_active: "Y" },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const counts = await query<{
|
||||
category: string;
|
||||
layout_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT category, layout_type, COUNT(*) as count
|
||||
FROM layout_standards
|
||||
WHERE company_code = $1 AND is_active = $2
|
||||
GROUP BY category, layout_type`,
|
||||
[companyCode, "Y"]
|
||||
);
|
||||
|
||||
// 결과 포맷팅
|
||||
const formattedCounts = counts.map((row) => ({
|
||||
category: row.category,
|
||||
layout_type: row.layout_type,
|
||||
_count: { id: parseInt(row.count) },
|
||||
}));
|
||||
```
|
||||
|
||||
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existingCodes = await prisma.layout_standards.findMany({
|
||||
where: { company_code: companyCode },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const existingCodes = await query<{ category: string }>(
|
||||
`SELECT DISTINCT category
|
||||
FROM layout_standards
|
||||
WHERE company_code = $1
|
||||
ORDER BY category`,
|
||||
[companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
|
||||
- [ ] **JSON 필드 처리 (layout_config, sections)**
|
||||
- [ ] **GROUP BY 집계 쿼리 전환**
|
||||
- [ ] **DISTINCT 쿼리 전환**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
- `layout_config`, `sections` 필드는 JSON 타입
|
||||
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
|
||||
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
|
||||
|
||||
### 2. 동적 검색 조건
|
||||
- category, layoutType, searchTerm에 따른 동적 WHERE 절
|
||||
- OR 조건 처리 (layout_name OR layout_code)
|
||||
|
||||
### 3. Soft Delete
|
||||
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
|
||||
- UPDATE 쿼리 사용
|
||||
|
||||
### 4. 통계 쿼리
|
||||
- `groupBy` → `GROUP BY` + `COUNT(*)` 전환
|
||||
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getLayouts() - count + findMany → query + queryOne
|
||||
- [ ] getLayoutByCode() - findFirst → queryOne
|
||||
- [ ] createLayout() - create → queryOne (INSERT)
|
||||
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
|
||||
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
|
||||
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
|
||||
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
|
||||
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### JSON 필드 헬퍼 함수
|
||||
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
|
||||
|
||||
### Soft Delete 패턴
|
||||
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
|
||||
|
||||
### 통계 쿼리 결과 포맷
|
||||
Prisma의 `groupBy`는 `_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 3.7)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함
|
||||
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
# 🗂️ Phase 3.8: DbTypeCategoryService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DbTypeCategoryService는 **10개의 Prisma 호출**이 있으며, 데이터베이스 타입 카테고리 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/dbTypeCategoryService.ts` |
|
||||
| 파일 크기 | 320+ 라인 |
|
||||
| Prisma 호출 | 10개 |
|
||||
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||
| 복잡도 | 중간 (CRUD, 통계, UPSERT) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.8) |
|
||||
| **상태** | ⏳ **대기 중** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ ApiResponse 래퍼 패턴 유지
|
||||
- ⏳ GROUP BY 통계 쿼리 전환
|
||||
- ⏳ UPSERT 로직 전환 (ON CONFLICT)
|
||||
- ⏳ 모든 단위 테스트 통과
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (10개)
|
||||
|
||||
#### 1. **getAllCategories()** - 카테고리 목록 조회
|
||||
```typescript
|
||||
// Line 45
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **getCategoryByTypeCode()** - 카테고리 단건 조회
|
||||
```typescript
|
||||
// Line 73
|
||||
const category = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: typeCode }
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **createCategory()** - 카테고리 생성
|
||||
```typescript
|
||||
// Line 105, 116
|
||||
const existing = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: data.type_code }
|
||||
});
|
||||
|
||||
const category = await prisma.db_type_categories.create({
|
||||
data: {
|
||||
type_code: data.type_code,
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
is_active: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **updateCategory()** - 카테고리 수정
|
||||
```typescript
|
||||
// Line 146
|
||||
const category = await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: updateData
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **deleteCategory()** - 카테고리 삭제 (연결 확인)
|
||||
```typescript
|
||||
// Line 179, 193
|
||||
const connectionsCount = await prisma.external_db_connections.count({
|
||||
where: { db_type: typeCode }
|
||||
});
|
||||
|
||||
await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: { is_active: false }
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. **getCategoryStatistics()** - 카테고리별 통계
|
||||
```typescript
|
||||
// Line 220, 229
|
||||
const stats = await prisma.external_db_connections.groupBy({
|
||||
by: ['db_type'],
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true }
|
||||
});
|
||||
```
|
||||
|
||||
#### 7. **syncPredefinedCategories()** - 사전 정의 카테고리 동기화
|
||||
```typescript
|
||||
// Line 300
|
||||
await prisma.db_type_categories.upsert({
|
||||
where: { type_code: category.type_code },
|
||||
update: {
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
},
|
||||
create: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getAllCategories()` - 목록 조회 (findMany)
|
||||
- `getCategoryByTypeCode()` - 단건 조회 (findUnique)
|
||||
- `createCategory()` - 생성 (findUnique + create)
|
||||
- `updateCategory()` - 수정 (update)
|
||||
- `deleteCategory()` - 삭제 (count + update - soft delete)
|
||||
|
||||
### 2단계: 통계 및 UPSERT 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getCategoryStatistics()` - 통계 (groupBy + findMany)
|
||||
- `syncPredefinedCategories()` - 동기화 (upsert)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 카테고리 목록 조회 (정렬)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const categories = await query<DbTypeCategory>(
|
||||
`SELECT * FROM db_type_categories
|
||||
WHERE is_active = $1
|
||||
ORDER BY sort_order ASC, display_name ASC`,
|
||||
[true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 카테고리 생성 (중복 확인)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existing = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: data.type_code }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 타입 코드입니다."
|
||||
};
|
||||
}
|
||||
|
||||
const category = await prisma.db_type_categories.create({
|
||||
data: {
|
||||
type_code: data.type_code,
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
is_active: true,
|
||||
}
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
const existing = await queryOne<DbTypeCategory>(
|
||||
`SELECT * FROM db_type_categories WHERE type_code = $1`,
|
||||
[data.type_code]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 타입 코드입니다."
|
||||
};
|
||||
}
|
||||
|
||||
const category = await queryOne<DbTypeCategory>(
|
||||
`INSERT INTO db_type_categories
|
||||
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.type_code,
|
||||
data.display_name,
|
||||
data.icon || null,
|
||||
data.color || null,
|
||||
data.sort_order ?? 0,
|
||||
true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 UPDATE (변경된 필드만)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const updateData: any = {};
|
||||
if (data.display_name !== undefined) updateData.display_name = data.display_name;
|
||||
if (data.icon !== undefined) updateData.icon = data.icon;
|
||||
if (data.color !== undefined) updateData.color = data.color;
|
||||
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
|
||||
if (data.is_active !== undefined) updateData.is_active = data.is_active;
|
||||
|
||||
const category = await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.display_name !== undefined) {
|
||||
updateFields.push(`display_name = $${paramIndex++}`);
|
||||
values.push(data.display_name);
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
updateFields.push(`icon = $${paramIndex++}`);
|
||||
values.push(data.icon);
|
||||
}
|
||||
if (data.color !== undefined) {
|
||||
updateFields.push(`color = $${paramIndex++}`);
|
||||
values.push(data.color);
|
||||
}
|
||||
if (data.sort_order !== undefined) {
|
||||
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||
values.push(data.sort_order);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
|
||||
const category = await queryOne<DbTypeCategory>(
|
||||
`UPDATE db_type_categories
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE type_code = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, typeCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 삭제 전 연결 확인
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const connectionsCount = await prisma.external_db_connections.count({
|
||||
where: { db_type: typeCode }
|
||||
});
|
||||
|
||||
if (connectionsCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: { is_active: false }
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const countResult = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
|
||||
[typeCode]
|
||||
);
|
||||
const connectionsCount = parseInt(countResult?.count || "0");
|
||||
|
||||
if (connectionsCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
|
||||
[false, typeCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 5: GROUP BY 통계 + JOIN
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const stats = await prisma.external_db_connections.groupBy({
|
||||
by: ['db_type'],
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true }
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const stats = await query<{
|
||||
type_code: string;
|
||||
display_name: string;
|
||||
connection_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
c.type_code,
|
||||
c.display_name,
|
||||
COUNT(e.id) as connection_count
|
||||
FROM db_type_categories c
|
||||
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
|
||||
WHERE c.is_active = $1
|
||||
GROUP BY c.type_code, c.display_name
|
||||
ORDER BY c.sort_order ASC`,
|
||||
[true]
|
||||
);
|
||||
|
||||
// 결과 포맷팅
|
||||
const result = stats.map(row => ({
|
||||
type_code: row.type_code,
|
||||
display_name: row.display_name,
|
||||
connection_count: parseInt(row.connection_count),
|
||||
}));
|
||||
```
|
||||
|
||||
### 예시 6: UPSERT (ON CONFLICT)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.db_type_categories.upsert({
|
||||
where: { type_code: category.type_code },
|
||||
update: {
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
},
|
||||
create: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
await query(
|
||||
`INSERT INTO db_type_categories
|
||||
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (type_code)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
icon = EXCLUDED.icon,
|
||||
color = EXCLUDED.color,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
category.type_code,
|
||||
category.display_name,
|
||||
category.icon || null,
|
||||
category.color || null,
|
||||
category.sort_order || 0,
|
||||
true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **동적 UPDATE 쿼리 생성**
|
||||
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
|
||||
- [ ] **ON CONFLICT를 사용한 UPSERT**
|
||||
- [ ] **ApiResponse 래퍼 패턴 유지**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. ApiResponse 래퍼 패턴
|
||||
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
|
||||
|
||||
### 2. Soft Delete 패턴
|
||||
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
|
||||
|
||||
### 3. 연결 확인
|
||||
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
|
||||
|
||||
### 4. UPSERT 로직
|
||||
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
|
||||
|
||||
### 5. 통계 쿼리 최적화
|
||||
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getAllCategories() - findMany → query
|
||||
- [ ] getCategoryByTypeCode() - findUnique → queryOne
|
||||
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
|
||||
- [ ] deleteCategory() - count + update → queryOne + query
|
||||
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
|
||||
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
|
||||
- [ ] ApiResponse 래퍼 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### ApiResponse 패턴
|
||||
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
|
||||
|
||||
### 사전 정의 카테고리
|
||||
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
|
||||
|
||||
### 외래 키 확인
|
||||
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 3.8)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함
|
||||
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/templateStandardService.ts` |
|
||||
| 파일 크기 | 395 라인 |
|
||||
| Prisma 호출 | 6개 |
|
||||
| **현재 진행률** | **7/7 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 낮음 (기본 CRUD + DISTINCT) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 3.9) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **7개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ✅ 템플릿 CRUD 기능 정상 동작
|
||||
- ✅ DISTINCT 쿼리 전환
|
||||
- ✅ Promise.all 병렬 쿼리 (목록 + 개수)
|
||||
- ✅ 동적 UPDATE 쿼리 (11개 필드)
|
||||
- ✅ TypeScript 컴파일 성공
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (6개)
|
||||
|
||||
#### 1. **getTemplateByCode()** - 템플릿 단건 조회
|
||||
|
||||
```typescript
|
||||
// Line 76
|
||||
return await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **createTemplate()** - 템플릿 생성
|
||||
|
||||
```typescript
|
||||
// Line 86
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: data.template_code,
|
||||
company_code: data.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
// Line 96
|
||||
return await prisma.template_standards.create({
|
||||
data: {
|
||||
...data,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **updateTemplate()** - 템플릿 수정
|
||||
|
||||
```typescript
|
||||
// Line 164
|
||||
return await prisma.template_standards.update({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **deleteTemplate()** - 템플릿 삭제
|
||||
|
||||
```typescript
|
||||
// Line 181
|
||||
await prisma.template_standards.delete({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT)
|
||||
|
||||
```typescript
|
||||
// Line 262
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
distinct: ["category"],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (4개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getTemplateByCode()` - 단건 조회 (findUnique)
|
||||
- `createTemplate()` - 생성 (findUnique + create)
|
||||
- `updateTemplate()` - 수정 (update)
|
||||
- `deleteTemplate()` - 삭제 (delete)
|
||||
|
||||
### 2단계: 추가 기능 전환 (1개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getTemplateCategories()` - 카테고리 목록 (findMany + distinct)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 복합 키 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
return await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { queryOne } from "../database/db";
|
||||
|
||||
return await queryOne<any>(
|
||||
`SELECT * FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 중복 확인 후 생성
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: data.template_code,
|
||||
company_code: data.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||
}
|
||||
|
||||
return await prisma.template_standards.create({
|
||||
data: {
|
||||
...data,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT * FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[data.template_code, data.company_code]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||
}
|
||||
|
||||
return await queryOne<any>(
|
||||
`INSERT INTO template_standards
|
||||
(template_code, template_name, category, template_type, layout_config,
|
||||
description, is_active, company_code, created_by, updated_by,
|
||||
created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.template_code,
|
||||
data.template_name,
|
||||
data.category,
|
||||
data.template_type,
|
||||
JSON.stringify(data.layout_config),
|
||||
data.description,
|
||||
data.is_active,
|
||||
data.company_code,
|
||||
data.created_by,
|
||||
data.updated_by,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 복합 키 UPDATE
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
return await prisma.template_standards.update({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
// 동적 UPDATE 쿼리 생성
|
||||
const updateFields: string[] = ["updated_date = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.template_name !== undefined) {
|
||||
updateFields.push(`template_name = $${paramIndex++}`);
|
||||
values.push(data.template_name);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
updateFields.push(`category = $${paramIndex++}`);
|
||||
values.push(data.category);
|
||||
}
|
||||
if (data.template_type !== undefined) {
|
||||
updateFields.push(`template_type = $${paramIndex++}`);
|
||||
values.push(data.template_type);
|
||||
}
|
||||
if (data.layout_config !== undefined) {
|
||||
updateFields.push(`layout_config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.layout_config));
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updateFields.push(`description = $${paramIndex++}`);
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
if (data.updated_by !== undefined) {
|
||||
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||
values.push(data.updated_by);
|
||||
}
|
||||
|
||||
return await queryOne<any>(
|
||||
`UPDATE template_standards
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 복합 키 DELETE
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.template_standards.delete({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(
|
||||
`DELETE FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 5: DISTINCT 쿼리
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
return categories
|
||||
.map((c) => c.category)
|
||||
.filter((c): c is string => c !== null && c !== undefined)
|
||||
.sort();
|
||||
|
||||
// 전환 후
|
||||
const categories = await query<{ category: string }>(
|
||||
`SELECT DISTINCT category
|
||||
FROM template_standards
|
||||
WHERE company_code = $1 AND category IS NOT NULL
|
||||
ORDER BY category ASC`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return categories.map((c) => c.category);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **복합 기본 키 처리 (template_code + company_code)**
|
||||
- [ ] **동적 UPDATE 쿼리 생성**
|
||||
- [ ] **DISTINCT 쿼리 전환**
|
||||
- [ ] **JSON 필드 처리 (layout_config)**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (6개)**
|
||||
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. 복합 기본 키
|
||||
|
||||
`template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다.
|
||||
|
||||
- WHERE 절에서 두 컬럼 모두 지정 필요
|
||||
- Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환
|
||||
|
||||
### 2. JSON 필드
|
||||
|
||||
`layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다.
|
||||
|
||||
### 3. DISTINCT + NULL 제외
|
||||
|
||||
카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다.
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
|
||||
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
|
||||
- [ ] deleteTemplate() - delete → query (복합 키)
|
||||
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
|
||||
- [ ] JSON 필드 처리 (layout_config)
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (6개)
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### 복합 기본 키 패턴
|
||||
|
||||
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
|
||||
|
||||
### JSON 레이아웃 설정
|
||||
|
||||
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
|
||||
|
||||
### 카테고리 관리
|
||||
|
||||
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 45분
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 3.9)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
# Phase 4.1: AdminController Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
관리자 컨트롤러의 Prisma 호출을 Raw Query로 전환합니다.
|
||||
사용자, 회사, 부서, 메뉴 관리 등 핵심 관리 기능을 포함합니다.
|
||||
|
||||
---
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/controllers/adminController.ts` |
|
||||
| 파일 크기 | 2,569 라인 |
|
||||
| Prisma 호출 | 28개 → 0개 |
|
||||
| **현재 진행률** | **28/28 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (다양한 CRUD 패턴) |
|
||||
| 우선순위 | 🔴 높음 (Phase 4.1) |
|
||||
| **상태** | ✅ **완료** (2025-10-01) |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 호출 분석
|
||||
|
||||
### 사용자 관리 (13개)
|
||||
|
||||
#### 1. getUserList (라인 312-317)
|
||||
|
||||
```typescript
|
||||
const totalCount = await prisma.user_info.count({ where });
|
||||
const users = await prisma.user_info.findMany({ where, skip, take, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: count → `queryOne`, findMany → `query`
|
||||
- **복잡도**: 중간 (동적 WHERE, 페이징)
|
||||
|
||||
#### 2. getUserInfo (라인 419)
|
||||
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findFirst({ where });
|
||||
```
|
||||
|
||||
- **전환**: findFirst → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 3. updateUserStatus (라인 498)
|
||||
|
||||
```typescript
|
||||
await prisma.user_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 4. deleteUserByAdmin (라인 2387)
|
||||
|
||||
```typescript
|
||||
await prisma.user_info.update({ where, data: { is_active: "N" } });
|
||||
```
|
||||
|
||||
- **전환**: update (soft delete) → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 5. getMyProfile (라인 1468, 1488, 2479)
|
||||
|
||||
```typescript
|
||||
const user = await prisma.user_info.findUnique({ where });
|
||||
const dept = await prisma.dept_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 6. updateMyProfile (라인 1864, 2527)
|
||||
|
||||
```typescript
|
||||
const updateResult = await prisma.user_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `queryOne` with RETURNING
|
||||
- **복잡도**: 중간 (동적 UPDATE)
|
||||
|
||||
#### 7. createOrUpdateUser (라인 1929, 1975)
|
||||
|
||||
```typescript
|
||||
const savedUser = await prisma.user_info.upsert({ where, update, create });
|
||||
const userCount = await prisma.user_info.count({ where });
|
||||
```
|
||||
|
||||
- **전환**: upsert → `INSERT ... ON CONFLICT`, count → `queryOne`
|
||||
- **복잡도**: 높음
|
||||
|
||||
#### 8. 기타 findUnique (라인 1596, 1832, 2393)
|
||||
|
||||
```typescript
|
||||
const existingUser = await prisma.user_info.findUnique({ where });
|
||||
const currentUser = await prisma.user_info.findUnique({ where });
|
||||
const updatedUser = await prisma.user_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 회사 관리 (7개)
|
||||
|
||||
#### 9. getCompanyList (라인 550, 1276)
|
||||
|
||||
```typescript
|
||||
const companies = await prisma.company_mng.findMany({ orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 10. createCompany (라인 2035)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findFirst({ where });
|
||||
```
|
||||
|
||||
- **전환**: findFirst (중복 체크) → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 11. updateCompany (라인 2172, 2192)
|
||||
|
||||
```typescript
|
||||
const duplicateCompany = await prisma.company_mng.findFirst({ where });
|
||||
const updatedCompany = await prisma.company_mng.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: findFirst → `queryOne`, update → `queryOne`
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 12. deleteCompany (라인 2261, 2281)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findUnique({ where });
|
||||
await prisma.company_mng.delete({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`, delete → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 부서 관리 (2개)
|
||||
|
||||
#### 13. getDepartmentList (라인 1348)
|
||||
|
||||
```typescript
|
||||
const departments = await prisma.dept_info.findMany({ where, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 14. getDeptInfo (라인 1488)
|
||||
|
||||
```typescript
|
||||
const dept = await prisma.dept_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 메뉴 관리 (3개)
|
||||
|
||||
#### 15. createMenu (라인 1021)
|
||||
|
||||
```typescript
|
||||
const savedMenu = await prisma.menu_info.create({ data });
|
||||
```
|
||||
|
||||
- **전환**: create → `queryOne` with INSERT RETURNING
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 16. updateMenu (라인 1087)
|
||||
|
||||
```typescript
|
||||
const updatedMenu = await prisma.menu_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `queryOne` with UPDATE RETURNING
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 17. deleteMenu (라인 1149, 1211)
|
||||
|
||||
```typescript
|
||||
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||
// 재귀 삭제
|
||||
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||
```
|
||||
|
||||
- **전환**: delete → `query`
|
||||
- **복잡도**: 중간 (재귀 삭제 로직)
|
||||
|
||||
### 다국어 (1개)
|
||||
|
||||
#### 18. getMultiLangKeys (라인 665)
|
||||
|
||||
```typescript
|
||||
const result = await prisma.multi_lang_key_master.findMany({ where, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 전략
|
||||
|
||||
### 1단계: Import 변경
|
||||
|
||||
```typescript
|
||||
// 제거
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 추가
|
||||
import { query, queryOne } from "../database/db";
|
||||
```
|
||||
|
||||
### 2단계: 단순 조회 전환
|
||||
|
||||
- findMany → `query<T>`
|
||||
- findUnique/findFirst → `queryOne<T>`
|
||||
|
||||
### 3단계: 동적 WHERE 처리
|
||||
|
||||
```typescript
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
```
|
||||
|
||||
### 4단계: 복잡한 로직 전환
|
||||
|
||||
- count → `SELECT COUNT(*) as count`
|
||||
- upsert → `INSERT ... ON CONFLICT DO UPDATE`
|
||||
- 동적 UPDATE → 조건부 SET 절 생성
|
||||
|
||||
### 5단계: 테스트 및 검증
|
||||
|
||||
- 각 함수별 동작 확인
|
||||
- 에러 처리 확인
|
||||
- 타입 안전성 확인
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 변경 예시
|
||||
|
||||
### getUserList (count + findMany)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const totalCount = await prisma.user_info.count({ where });
|
||||
const users = await prisma.user_info.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
// After
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 동적 WHERE 구성
|
||||
if (where.company_code) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(where.company_code);
|
||||
}
|
||||
if (where.user_name) {
|
||||
whereConditions.push(`user_name ILIKE $${paramIndex++}`);
|
||||
params.push(`%${where.user_name}%`);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// Count
|
||||
const countResult = await queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM user_info ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const totalCount = parseInt(countResult?.count?.toString() || "0", 10);
|
||||
|
||||
// 데이터 조회
|
||||
const usersQuery = `
|
||||
SELECT * FROM user_info
|
||||
${whereClause}
|
||||
ORDER BY created_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(take, skip);
|
||||
|
||||
const users = await query<UserInfo>(usersQuery, params);
|
||||
```
|
||||
|
||||
### createOrUpdateUser (upsert)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const savedUser = await prisma.user_info.upsert({
|
||||
where: { user_id: userId },
|
||||
update: updateData,
|
||||
create: createData
|
||||
});
|
||||
|
||||
// After
|
||||
const savedUser = await queryOne<UserInfo>(
|
||||
`INSERT INTO user_info (user_id, user_name, email, ...)
|
||||
VALUES ($1, $2, $3, ...)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
email = EXCLUDED.email,
|
||||
...
|
||||
RETURNING *`,
|
||||
[userId, userName, email, ...]
|
||||
);
|
||||
```
|
||||
|
||||
### updateMyProfile (동적 UPDATE)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updateResult = await prisma.user_info.update({
|
||||
where: { user_id: userId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// After
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updateData.user_name !== undefined) {
|
||||
updates.push(`user_name = $${paramIndex++}`);
|
||||
params.push(updateData.user_name);
|
||||
}
|
||||
if (updateData.email !== undefined) {
|
||||
updates.push(`email = $${paramIndex++}`);
|
||||
params.push(updateData.email);
|
||||
}
|
||||
// ... 다른 필드들
|
||||
|
||||
params.push(userId);
|
||||
|
||||
const updateResult = await queryOne<UserInfo>(
|
||||
`UPDATE user_info
|
||||
SET ${updates.join(", ")}, updated_date = NOW()
|
||||
WHERE user_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 기본 설정
|
||||
|
||||
- ✅ Prisma import 제거 (완전 제거 확인)
|
||||
- ✅ query, queryOne import 추가 (이미 존재)
|
||||
- ✅ 타입 import 확인
|
||||
|
||||
### 사용자 관리
|
||||
|
||||
- ✅ getUserList (count + findMany → Raw Query)
|
||||
- ✅ getUserLocale (findFirst → queryOne)
|
||||
- ✅ setUserLocale (update → query)
|
||||
- ✅ getUserInfo (findUnique → queryOne)
|
||||
- ✅ checkDuplicateUserId (findUnique → queryOne)
|
||||
- ✅ changeUserStatus (findUnique + update → queryOne + query)
|
||||
- ✅ saveUser (upsert → INSERT ON CONFLICT)
|
||||
- ✅ updateProfile (동적 update → 동적 query)
|
||||
- ✅ resetUserPassword (update → query)
|
||||
|
||||
### 회사 관리
|
||||
|
||||
- ✅ getCompanyList (findMany → query)
|
||||
- ✅ getCompanyListFromDB (findMany → query)
|
||||
- ✅ createCompany (findFirst → queryOne)
|
||||
- ✅ updateCompany (findFirst + update → queryOne + query)
|
||||
- ✅ deleteCompany (delete → query with RETURNING)
|
||||
|
||||
### 부서 관리
|
||||
|
||||
- ✅ getDepartmentList (findMany → query with 동적 WHERE)
|
||||
|
||||
### 메뉴 관리
|
||||
|
||||
- ✅ saveMenu (create → query with INSERT RETURNING)
|
||||
- ✅ updateMenu (update → query with UPDATE RETURNING)
|
||||
- ✅ deleteMenu (delete → query with DELETE RETURNING)
|
||||
- ✅ deleteMenusBatch (다중 delete → 반복 query)
|
||||
|
||||
### 다국어
|
||||
|
||||
- ✅ getLangKeyList (findMany → query)
|
||||
|
||||
### 검증
|
||||
|
||||
- ✅ TypeScript 컴파일 확인 (에러 없음)
|
||||
- ✅ Linter 오류 확인
|
||||
- ⏳ 기능 테스트 (실행 필요)
|
||||
- ✅ 에러 처리 확인 (기존 구조 유지)
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### 동적 쿼리 생성 패턴
|
||||
|
||||
모든 동적 WHERE/UPDATE는 다음 패턴을 따릅니다:
|
||||
|
||||
1. 조건/필드 배열 생성
|
||||
2. 파라미터 배열 생성
|
||||
3. 파라미터 인덱스 관리
|
||||
4. SQL 문자열 조합
|
||||
5. query/queryOne 실행
|
||||
|
||||
### 에러 처리
|
||||
|
||||
기존 try-catch 구조를 유지하며, 데이터베이스 에러를 적절히 변환합니다.
|
||||
|
||||
### 트랜잭션
|
||||
|
||||
복잡한 로직은 Service Layer로 이동을 고려합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 완료 요약 (2025-10-01)
|
||||
|
||||
### ✅ 전환 완료 현황
|
||||
|
||||
| 카테고리 | 함수 수 | 상태 |
|
||||
|---------|--------|------|
|
||||
| 사용자 관리 | 9개 | ✅ 완료 |
|
||||
| 회사 관리 | 5개 | ✅ 완료 |
|
||||
| 부서 관리 | 1개 | ✅ 완료 |
|
||||
| 메뉴 관리 | 4개 | ✅ 완료 |
|
||||
| 다국어 | 1개 | ✅ 완료 |
|
||||
| **총계** | **20개** | **✅ 100% 완료** |
|
||||
|
||||
### 📊 주요 성과
|
||||
|
||||
1. **완전한 Prisma 제거**: adminController.ts에서 모든 Prisma 코드 제거 완료
|
||||
2. **동적 쿼리 지원**: 런타임 테이블 생성/수정 가능
|
||||
3. **일관된 에러 처리**: 모든 함수에서 통일된 에러 처리 유지
|
||||
4. **타입 안전성**: TypeScript 컴파일 에러 없음
|
||||
5. **코드 품질 향상**: 949줄 변경 (+474/-475)
|
||||
|
||||
### 🔑 주요 변환 패턴
|
||||
|
||||
#### 1. 동적 WHERE 조건
|
||||
```typescript
|
||||
let whereConditions: string[] = [];
|
||||
let queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filter) {
|
||||
whereConditions.push(`field = $${paramIndex}`);
|
||||
queryParams.push(filter);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
```
|
||||
|
||||
#### 2. UPSERT (INSERT ON CONFLICT)
|
||||
```typescript
|
||||
const [result] = await query<any>(
|
||||
`INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
ON CONFLICT (col1) DO UPDATE SET col2 = $2
|
||||
RETURNING *`,
|
||||
[val1, val2]
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. 동적 UPDATE
|
||||
```typescript
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.field !== undefined) {
|
||||
updateFields.push(`field = $${paramIndex}`);
|
||||
updateValues.push(data.field);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE table SET ${updateFields.join(", ")} WHERE id = $${paramIndex}`,
|
||||
[...updateValues, id]
|
||||
);
|
||||
```
|
||||
|
||||
### 🚀 다음 단계
|
||||
|
||||
1. **테스트 실행**: 개발 서버에서 모든 API 엔드포인트 테스트
|
||||
2. **문서 업데이트**: Phase 4 전체 계획서 진행 상황 반영
|
||||
3. **다음 Phase**: screenFileController.ts 마이그레이션 진행
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-10-01
|
||||
**작업자**: Claude Agent
|
||||
**완료 시간**: 약 15분
|
||||
**변경 라인 수**: 949줄 (추가 474줄, 삭제 475줄)
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
# Phase 4: Controller Layer Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
컨트롤러 레이어에 남아있는 Prisma 호출을 Raw Query로 전환합니다.
|
||||
대부분의 컨트롤러는 Service 레이어를 호출하지만, 일부 컨트롤러에서 직접 Prisma를 사용하고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------- |
|
||||
| 대상 파일 | 7개 컨트롤러 |
|
||||
| 파일 위치 | `backend-node/src/controllers/` |
|
||||
| Prisma 호출 | 70개 (28개 완료) |
|
||||
| **현재 진행률** | **28/70 (40%)** 🔄 **진행 중** |
|
||||
| 복잡도 | 중간 (대부분 단순 CRUD) |
|
||||
| 우선순위 | 🟡 중간 (Phase 4) |
|
||||
| **상태** | 🔄 **진행 중** (adminController 완료) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 전환 대상 컨트롤러
|
||||
|
||||
### 1. adminController.ts ✅ 완료 (28개)
|
||||
|
||||
- **라인 수**: 2,569 라인
|
||||
- **Prisma 호출**: 28개 → 0개
|
||||
- **주요 기능**:
|
||||
- 사용자 관리 (조회, 생성, 수정, 삭제) ✅
|
||||
- 회사 관리 (조회, 생성, 수정, 삭제) ✅
|
||||
- 부서 관리 (조회) ✅
|
||||
- 메뉴 관리 (생성, 수정, 삭제) ✅
|
||||
- 다국어 키 조회 ✅
|
||||
- **우선순위**: 🔴 높음
|
||||
- **상태**: ✅ **완료** (2025-10-01)
|
||||
- **문서**: [PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md](PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md)
|
||||
|
||||
### 2. webTypeStandardController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 웹타입 표준 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 3. fileController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 파일 업로드/다운로드 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 4. buttonActionStandardController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 버튼 액션 표준 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 5. entityReferenceController.ts (4개)
|
||||
|
||||
- **Prisma 호출**: 4개
|
||||
- **주요 기능**: 엔티티 참조 관리
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
### 6. dataflowExecutionController.ts (3개)
|
||||
|
||||
- **Prisma 호출**: 3개
|
||||
- **주요 기능**: 데이터플로우 실행
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
### 7. screenFileController.ts (2개)
|
||||
|
||||
- **Prisma 호출**: 2개
|
||||
- **주요 기능**: 화면 파일 관리
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 전략
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
1. **Service Layer 우선**
|
||||
|
||||
- 가능하면 Service로 로직 이동
|
||||
- Controller는 최소한의 로직만 유지
|
||||
|
||||
2. **단순 전환**
|
||||
|
||||
- 대부분 단순 CRUD → `query`, `queryOne` 사용
|
||||
- 복잡한 로직은 Service로 이동
|
||||
|
||||
3. **에러 처리 유지**
|
||||
- 기존 try-catch 구조 유지
|
||||
- 에러 메시지 일관성 유지
|
||||
|
||||
### 전환 패턴
|
||||
|
||||
#### 1. findMany → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const users = await prisma.user_info.findMany({
|
||||
where: { company_code: companyCode },
|
||||
});
|
||||
|
||||
// After
|
||||
const users = await query<UserInfo>(
|
||||
`SELECT * FROM user_info WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. findUnique → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const user = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
// After
|
||||
const user = await queryOne<UserInfo>(
|
||||
`SELECT * FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. create → queryOne with INSERT
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newUser = await prisma.user_info.create({
|
||||
data: userData
|
||||
});
|
||||
|
||||
// After
|
||||
const newUser = await queryOne<UserInfo>(
|
||||
`INSERT INTO user_info (user_id, user_name, ...)
|
||||
VALUES ($1, $2, ...) RETURNING *`,
|
||||
[userData.user_id, userData.user_name, ...]
|
||||
);
|
||||
```
|
||||
|
||||
#### 4. update → queryOne with UPDATE
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updated = await prisma.user_info.update({
|
||||
where: { user_id: userId },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// After
|
||||
const updated = await queryOne<UserInfo>(
|
||||
`UPDATE user_info SET user_name = $1, ...
|
||||
WHERE user_id = $2 RETURNING *`,
|
||||
[updateData.user_name, ..., userId]
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. delete → query with DELETE
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.user_info.delete({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
// After
|
||||
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||
```
|
||||
|
||||
#### 6. count → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const count = await prisma.user_info.count({
|
||||
where: { company_code: companyCode },
|
||||
});
|
||||
|
||||
// After
|
||||
const result = await queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM user_info WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.count?.toString() || "0", 10);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### Phase 4.1: adminController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 사용자 관리 함수 전환 (8개)
|
||||
- [ ] getUserList - count + findMany
|
||||
- [ ] getUserInfo - findFirst
|
||||
- [ ] updateUserStatus - update
|
||||
- [ ] deleteUserByAdmin - update
|
||||
- [ ] getMyProfile - findUnique
|
||||
- [ ] updateMyProfile - update
|
||||
- [ ] createOrUpdateUser - upsert
|
||||
- [ ] count (getUserList)
|
||||
- [ ] 회사 관리 함수 전환 (7개)
|
||||
- [ ] getCompanyList - findMany
|
||||
- [ ] createCompany - findFirst (중복체크) + create
|
||||
- [ ] updateCompany - findFirst (중복체크) + update
|
||||
- [ ] deleteCompany - findUnique + delete
|
||||
- [ ] 부서 관리 함수 전환 (2개)
|
||||
- [ ] getDepartmentList - findMany
|
||||
- [ ] findUnique (부서 조회)
|
||||
- [ ] 메뉴 관리 함수 전환 (3개)
|
||||
- [ ] createMenu - create
|
||||
- [ ] updateMenu - update
|
||||
- [ ] deleteMenu - delete
|
||||
- [ ] 기타 함수 전환 (8개)
|
||||
- [ ] getMultiLangKeys - findMany
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.2: webTypeStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.3: fileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.4: buttonActionStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.5: entityReferenceController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (4개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.6: dataflowExecutionController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (3개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.7: screenFileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (2개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 결과
|
||||
|
||||
### 코드 품질
|
||||
|
||||
- ✅ Prisma 의존성 완전 제거
|
||||
- ✅ 직접적인 SQL 제어
|
||||
- ✅ 타입 안전성 유지
|
||||
|
||||
### 성능
|
||||
|
||||
- ✅ 불필요한 ORM 오버헤드 제거
|
||||
- ✅ 쿼리 최적화 가능
|
||||
|
||||
### 유지보수성
|
||||
|
||||
- ✅ 명확한 SQL 쿼리
|
||||
- ✅ 디버깅 용이
|
||||
- ✅ 데이터베이스 마이그레이션 용이
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### Import 변경
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// After
|
||||
import { query, queryOne } from "../database/db";
|
||||
```
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- 각 테이블의 타입은 `types/` 디렉토리에서 import
|
||||
- 필요시 새로운 타입 정의 추가
|
||||
|
||||
### 에러 처리
|
||||
|
||||
- 기존 try-catch 구조 유지
|
||||
- 적절한 HTTP 상태 코드 반환
|
||||
- 사용자 친화적 에러 메시지
|
||||
|
|
@ -1,546 +0,0 @@
|
|||
# Phase 4: 남은 Prisma 호출 전환 계획
|
||||
|
||||
## 📊 현재 상황
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------- |
|
||||
| 총 Prisma 호출 | 29개 |
|
||||
| 대상 파일 | 7개 |
|
||||
| **현재 진행률** | **17/29 (58.6%)** 🔄 **진행 중** |
|
||||
| 복잡도 | 중간 |
|
||||
| 우선순위 | 🔴 높음 (Phase 4) |
|
||||
| **상태** | ⏳ **진행 중** |
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일별 현황
|
||||
|
||||
### ✅ 완료된 파일 (2개)
|
||||
|
||||
1. **adminController.ts** - ✅ **28개 완료**
|
||||
|
||||
- 사용자 관리: getUserList, getUserInfo, updateUserStatus, deleteUser
|
||||
- 프로필 관리: getMyProfile, updateMyProfile, resetPassword
|
||||
- 사용자 생성/수정: createOrUpdateUser (UPSERT)
|
||||
- 회사 관리: getCompanyList, createCompany, updateCompany, deleteCompany
|
||||
- 부서 관리: getDepartmentList, getDeptInfo
|
||||
- 메뉴 관리: createMenu, updateMenu, deleteMenu
|
||||
- 다국어: getMultiLangKeys, updateLocale
|
||||
|
||||
2. **screenFileController.ts** - ✅ **2개 완료**
|
||||
- getScreenComponentFiles: findMany → query (LIKE)
|
||||
- getComponentFiles: findMany → query (LIKE)
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 남은 파일 (5개, 총 12개 호출)
|
||||
|
||||
### 1. webTypeStandardController.ts (11개) 🔴 최우선
|
||||
|
||||
**위치**: `backend-node/src/controllers/webTypeStandardController.ts`
|
||||
|
||||
#### Prisma 호출 목록:
|
||||
|
||||
1. **라인 33**: `getWebTypeStandards()` - findMany
|
||||
|
||||
```typescript
|
||||
const webTypes = await prisma.web_type_standards.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select,
|
||||
});
|
||||
```
|
||||
|
||||
2. **라인 58**: `getWebTypeStandard()` - findUnique
|
||||
|
||||
```typescript
|
||||
const webTypeData = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
3. **라인 112**: `createWebTypeStandard()` - findUnique (중복 체크)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
```
|
||||
|
||||
4. **라인 123**: `createWebTypeStandard()` - create
|
||||
|
||||
```typescript
|
||||
const newWebType = await prisma.web_type_standards.create({
|
||||
data: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
5. **라인 178**: `updateWebTypeStandard()` - findUnique (존재 확인)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
6. **라인 189**: `updateWebTypeStandard()` - update
|
||||
|
||||
```typescript
|
||||
const updatedWebType = await prisma.web_type_standards.update({
|
||||
where: { id }, data: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
7. **라인 230**: `deleteWebTypeStandard()` - findUnique (존재 확인)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
8. **라인 241**: `deleteWebTypeStandard()` - delete
|
||||
|
||||
```typescript
|
||||
await prisma.web_type_standards.delete({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
9. **라인 275**: `updateSortOrder()` - $transaction
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(
|
||||
updates.map((item) =>
|
||||
prisma.web_type_standards.update({ ... })
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
10. **라인 277**: `updateSortOrder()` - update (트랜잭션 내부)
|
||||
|
||||
11. **라인 305**: `getCategories()` - groupBy
|
||||
```typescript
|
||||
const categories = await prisma.web_type_standards.groupBy({
|
||||
by: ["category"],
|
||||
where,
|
||||
_count: true,
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findMany → `query<WebTypeStandard>` with dynamic WHERE
|
||||
- findUnique → `queryOne<WebTypeStandard>`
|
||||
- create → `queryOne` with INSERT RETURNING
|
||||
- update → `queryOne` with UPDATE RETURNING
|
||||
- delete → `query` with DELETE
|
||||
- $transaction → `transaction` with client.query
|
||||
- groupBy → `query` with GROUP BY, COUNT
|
||||
|
||||
---
|
||||
|
||||
### 2. fileController.ts (1개) 🟡
|
||||
|
||||
**위치**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 726**: `downloadFile()` - findUnique
|
||||
```typescript
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: BigInt(objid) },
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findUnique → `queryOne<AttachFileInfo>`
|
||||
|
||||
---
|
||||
|
||||
### 3. multiConnectionQueryService.ts (4개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/services/multiConnectionQueryService.ts`
|
||||
|
||||
#### Prisma 호출 목록:
|
||||
|
||||
1. **라인 1005**: `executeSelect()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(query, ...queryParams);
|
||||
```
|
||||
|
||||
2. **라인 1022**: `executeInsert()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
const insertResult = await prisma.$queryRawUnsafe(...);
|
||||
```
|
||||
|
||||
3. **라인 1055**: `executeUpdate()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(updateQuery, ...updateParams);
|
||||
```
|
||||
|
||||
4. **라인 1071**: `executeDelete()` - $queryRawUnsafe
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(...);
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- $queryRawUnsafe → `query<any>` (이미 Raw SQL 사용 중)
|
||||
|
||||
---
|
||||
|
||||
### 4. config/database.ts (4개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/config/database.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 1**: PrismaClient import
|
||||
2. **라인 17**: prisma 인스턴스 생성
|
||||
3. **라인 22**: `await prisma.$connect()`
|
||||
4. **라인 31, 35, 40**: `await prisma.$disconnect()`
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- 이 파일은 데이터베이스 설정 파일이므로 완전히 제거
|
||||
- 기존 `db.ts`의 connection pool로 대체
|
||||
- 모든 import 경로를 `database` → `database/db`로 변경
|
||||
|
||||
---
|
||||
|
||||
### 5. routes/ddlRoutes.ts (2개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/routes/ddlRoutes.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 183-184**: 동적 PrismaClient import
|
||||
|
||||
```typescript
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
```
|
||||
|
||||
2. **라인 186-187**: 연결 테스트
|
||||
```typescript
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await prisma.$disconnect();
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- 동적 import 제거
|
||||
- `query('SELECT 1')` 사용
|
||||
|
||||
---
|
||||
|
||||
### 6. routes/companyManagementRoutes.ts (2개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/routes/companyManagementRoutes.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 32**: findUnique (중복 체크)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findUnique({
|
||||
where: { company_code },
|
||||
});
|
||||
```
|
||||
|
||||
2. **라인 61**: update (회사명 업데이트)
|
||||
```typescript
|
||||
await prisma.company_mng.update({
|
||||
where: { company_code },
|
||||
data: { company_name },
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findUnique → `queryOne`
|
||||
- update → `query`
|
||||
|
||||
---
|
||||
|
||||
### 7. tests/authService.test.ts (2개) ⚠️
|
||||
|
||||
**위치**: `backend-node/src/tests/authService.test.ts`
|
||||
|
||||
테스트 파일은 별도 처리 필요 (Phase 5에서 처리)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 전환 우선순위
|
||||
|
||||
### Phase 4.1: 컨트롤러 (완료)
|
||||
|
||||
- [x] screenFileController.ts (2개)
|
||||
- [x] adminController.ts (28개)
|
||||
|
||||
### Phase 4.2: 남은 컨트롤러 (진행 예정)
|
||||
|
||||
- [ ] webTypeStandardController.ts (11개) - 🔴 최우선
|
||||
- [ ] fileController.ts (1개)
|
||||
|
||||
### Phase 4.3: Routes (진행 예정)
|
||||
|
||||
- [ ] ddlRoutes.ts (2개)
|
||||
- [ ] companyManagementRoutes.ts (2개)
|
||||
|
||||
### Phase 4.4: Services (진행 예정)
|
||||
|
||||
- [ ] multiConnectionQueryService.ts (4개)
|
||||
|
||||
### Phase 4.5: Config (진행 예정)
|
||||
|
||||
- [ ] database.ts (4개) - 전체 파일 제거
|
||||
|
||||
### Phase 4.6: Tests (Phase 5)
|
||||
|
||||
- [ ] authService.test.ts (2개) - 별도 처리
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### webTypeStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] getWebTypeStandards (findMany → query)
|
||||
- [ ] getWebTypeStandard (findUnique → queryOne)
|
||||
- [ ] createWebTypeStandard (findUnique + create → queryOne)
|
||||
- [ ] updateWebTypeStandard (findUnique + update → queryOne)
|
||||
- [ ] deleteWebTypeStandard (findUnique + delete → query)
|
||||
- [ ] updateSortOrder ($transaction → transaction)
|
||||
- [ ] getCategories (groupBy → query with GROUP BY)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
- [ ] Linter 오류 확인
|
||||
- [ ] 동작 테스트
|
||||
|
||||
### fileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] queryOne import 추가
|
||||
- [ ] downloadFile (findUnique → queryOne)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### routes/ddlRoutes.ts
|
||||
|
||||
- [ ] 동적 PrismaClient import 제거
|
||||
- [ ] query import 추가
|
||||
- [ ] 연결 테스트 로직 변경
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### routes/companyManagementRoutes.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] findUnique → queryOne
|
||||
- [ ] update → query
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### services/multiConnectionQueryService.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query import 추가
|
||||
- [ ] $queryRawUnsafe → query (4곳)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### config/database.ts
|
||||
|
||||
- [ ] 파일 전체 분석
|
||||
- [ ] 의존성 확인
|
||||
- [ ] 대체 방안 구현
|
||||
- [ ] 모든 import 경로 변경
|
||||
- [ ] 파일 삭제 또는 완전 재작성
|
||||
|
||||
---
|
||||
|
||||
## 🔧 전환 패턴 요약
|
||||
|
||||
### 1. findMany → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const items = await prisma.table.findMany({ where, orderBy });
|
||||
|
||||
// After
|
||||
const items = await query<T>(
|
||||
`SELECT * FROM table WHERE ... ORDER BY ...`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
### 2. findUnique → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const item = await prisma.table.findUnique({ where: { id } });
|
||||
|
||||
// After
|
||||
const item = await queryOne<T>(`SELECT * FROM table WHERE id = $1`, [id]);
|
||||
```
|
||||
|
||||
### 3. create → queryOne with RETURNING
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newItem = await prisma.table.create({ data });
|
||||
|
||||
// After
|
||||
const [newItem] = await query<T>(
|
||||
`INSERT INTO table (col1, col2) VALUES ($1, $2) RETURNING *`,
|
||||
[val1, val2]
|
||||
);
|
||||
```
|
||||
|
||||
### 4. update → query with RETURNING
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updated = await prisma.table.update({ where, data });
|
||||
|
||||
// After
|
||||
const [updated] = await query<T>(
|
||||
`UPDATE table SET col1 = $1 WHERE id = $2 RETURNING *`,
|
||||
[val1, id]
|
||||
);
|
||||
```
|
||||
|
||||
### 5. delete → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.table.delete({ where: { id } });
|
||||
|
||||
// After
|
||||
await query(`DELETE FROM table WHERE id = $1`, [id]);
|
||||
```
|
||||
|
||||
### 6. $transaction → transaction
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.$transaction([
|
||||
prisma.table.update({ ... }),
|
||||
prisma.table.update({ ... })
|
||||
]);
|
||||
|
||||
// After
|
||||
await transaction(async (client) => {
|
||||
await client.query(`UPDATE table SET ...`, params1);
|
||||
await client.query(`UPDATE table SET ...`, params2);
|
||||
});
|
||||
```
|
||||
|
||||
### 7. groupBy → query with GROUP BY
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await prisma.table.groupBy({
|
||||
by: ["category"],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// After
|
||||
const result = await query<T>(
|
||||
`SELECT category, COUNT(*) as count FROM table GROUP BY category`,
|
||||
[]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 진행 상황
|
||||
|
||||
### 전체 진행률: 17/29 (58.6%)
|
||||
|
||||
```
|
||||
Phase 1-3: Service Layer ████████████████████████████ 100% (415/415)
|
||||
Phase 4.1: Controllers ████████████████████████████ 100% (30/30)
|
||||
Phase 4.2: 남은 파일 ███████░░░░░░░░░░░░░░░░░░░░ 58% (17/29)
|
||||
```
|
||||
|
||||
### 상세 진행 상황
|
||||
|
||||
| 카테고리 | 완료 | 남음 | 진행률 |
|
||||
| ----------- | ---- | ---- | ------ |
|
||||
| Services | 415 | 0 | 100% |
|
||||
| Controllers | 30 | 11 | 73% |
|
||||
| Routes | 0 | 4 | 0% |
|
||||
| Config | 0 | 4 | 0% |
|
||||
| **총계** | 445 | 19 | 95.9% |
|
||||
|
||||
---
|
||||
|
||||
## 🎬 다음 단계
|
||||
|
||||
1. **webTypeStandardController.ts 전환** (11개)
|
||||
|
||||
- 가장 많은 Prisma 호출을 가진 남은 컨트롤러
|
||||
- 웹 타입 표준 관리 핵심 기능
|
||||
|
||||
2. **fileController.ts 전환** (1개)
|
||||
|
||||
- 단순 findUnique만 있어 빠르게 처리 가능
|
||||
|
||||
3. **Routes 전환** (4개)
|
||||
|
||||
- ddlRoutes.ts
|
||||
- companyManagementRoutes.ts
|
||||
|
||||
4. **Service 전환** (4개)
|
||||
|
||||
- multiConnectionQueryService.ts
|
||||
|
||||
5. **Config 제거** (4개)
|
||||
- database.ts 완전 제거 또는 재작성
|
||||
- 모든 의존성 제거
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **database.ts 처리**
|
||||
|
||||
- 현재 많은 파일이 `import prisma from '../config/database'` 사용
|
||||
- 모든 import를 `import { query, queryOne } from '../database/db'`로 변경 필요
|
||||
- 단계적으로 진행하여 빌드 오류 방지
|
||||
|
||||
2. **BigInt 처리**
|
||||
|
||||
- fileController의 `objid: BigInt(objid)` → `objid::bigint` 또는 `CAST(objid AS BIGINT)`
|
||||
|
||||
3. **트랜잭션 처리**
|
||||
|
||||
- webTypeStandardController의 `updateSortOrder`는 복잡한 트랜잭션
|
||||
- `transaction` 함수 사용 필요
|
||||
|
||||
4. **타입 안전성**
|
||||
- 모든 Raw Query에 명시적 타입 지정 필요
|
||||
- `query<WebTypeStandard>`, `queryOne<AttachFileInfo>` 등
|
||||
|
||||
---
|
||||
|
||||
## 📝 완료 후 작업
|
||||
|
||||
- [ ] 전체 컴파일 확인
|
||||
- [ ] Linter 오류 해결
|
||||
- [ ] 통합 테스트 실행
|
||||
- [ ] Prisma 관련 의존성 완전 제거 (package.json)
|
||||
- [ ] `prisma/` 디렉토리 정리
|
||||
- [ ] 문서 업데이트
|
||||
- [ ] 커밋 및 Push
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**최종 업데이트**: 2025-10-01
|
||||
**상태**: 🔄 진행 중 (58.6% 완료)
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
||||
|
||||
## 개요
|
||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
### 1. 단일 화면 복제
|
||||
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
||||
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
||||
- [x] 연결된 모달 화면 함께 복제
|
||||
- [x] 대상 그룹 선택 가능
|
||||
- [x] 복제 후 목록 자동 새로고침
|
||||
|
||||
### 2. 그룹(폴더) 전체 복제
|
||||
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
||||
- [x] 정렬 순서(display_order) 유지
|
||||
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
||||
- [x] 정렬 순서 입력 필드 추가
|
||||
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
|
||||
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
|
||||
|
||||
### 3. 고급 옵션: 이름 일괄 변경
|
||||
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
|
||||
- [x] 미리보기 기능
|
||||
|
||||
### 4. 삭제 기능
|
||||
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
||||
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
|
||||
- [x] 삭제 시 로딩 프로그레스 바 표시
|
||||
|
||||
### 5. 화면 수정 기능
|
||||
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
|
||||
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
|
||||
|
||||
### 6. 테이블 설정 기능 (TableSettingModal)
|
||||
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
|
||||
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
|
||||
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
|
||||
- 코드→다른 타입: codeCategory, codeValue 초기화
|
||||
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
|
||||
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
|
||||
|
||||
### 7. 회사 코드 지원 (최고 관리자)
|
||||
- [x] 대상 회사 선택 가능
|
||||
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
|
||||
|
||||
## 관련 파일
|
||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
|
||||
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
|
||||
- `frontend/lib/api/screen.ts` - 화면 API
|
||||
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
||||
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
|
||||
|
||||
## 진행 상태
|
||||
- [완료] 단일 화면 복제 + 새로고침
|
||||
- [완료] 그룹 전체 복제 (재귀적)
|
||||
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
|
||||
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
|
||||
- [완료] 화면 수정 (이름/그룹/역할/순서)
|
||||
- [완료] 테이블 설정 탭 추가
|
||||
- [완료] 입력 타입 변경 시 관련 필드 초기화
|
||||
- [완료] 그룹 복제 모달 스크롤 문제 수정
|
||||
|
||||
---
|
||||
|
||||
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||
3. **프론트엔드 UI 개선**:
|
||||
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||
|
||||
## 테스트 계획
|
||||
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||
|
||||
### 2단계: 백엔드 로직 구현
|
||||
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||
- [x] 커넥션 상세 조회 API 확인
|
||||
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||
|
||||
### 3단계: 프론트엔드 구현
|
||||
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
- [완료] 모든 단계 구현 완료
|
||||
|
|
@ -0,0 +1,680 @@
|
|||
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
|
||||
|
||||
## 1. 개요
|
||||
|
||||
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
|
||||
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
|
||||
|
||||
### 현재 컴포넌트 현황 (AS-IS)
|
||||
|
||||
| 카테고리 | 파일 수 | 주요 파일들 |
|
||||
| :------------- | :-----: | :------------------------------------------------------------------ |
|
||||
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
|
||||
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
|
||||
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
|
||||
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 전략: 9 Core Widgets
|
||||
|
||||
### A. 입력 위젯 (Input Widgets) - 5종
|
||||
|
||||
단순 데이터 입력 필드를 통합합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
|
||||
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
|
||||
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
|
||||
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
|
||||
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
|
||||
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
|
||||
|
||||
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
|
||||
|
||||
레이아웃 배치와 데이터 시각화를 담당합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
|
||||
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
|
||||
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
|
||||
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
|
||||
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
|
||||
|
||||
### C. Config Panel 통합 전략 (핵심)
|
||||
|
||||
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
|
||||
|
||||
| AS-IS | TO-BE | 방식 |
|
||||
| :-------------------- | :--------------------- | :------------------------------- |
|
||||
| TextConfigPanel.tsx | | |
|
||||
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
|
||||
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
|
||||
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
|
||||
| ... 24개 더 | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 시나리오 (속성 기반 변신)
|
||||
|
||||
### Case 1: "테이블을 카드 리스트로 변경"
|
||||
|
||||
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
|
||||
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영.
|
||||
|
||||
### Case 2: "단일 선택을 라디오 버튼으로 변경"
|
||||
|
||||
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
|
||||
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경.
|
||||
|
||||
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
|
||||
|
||||
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 로드맵 (Action Plan)
|
||||
|
||||
### Phase 0: 준비 단계 (1주)
|
||||
|
||||
통합 작업 전 필수 분석 및 설계를 진행합니다.
|
||||
|
||||
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
|
||||
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
|
||||
- [ ] `sys_input_type` 테이블 JSON Schema 설계
|
||||
- [ ] DynamicConfigPanel 프로토타입 설계
|
||||
|
||||
### Phase 1: 입력 위젯 통합 (2주)
|
||||
|
||||
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
|
||||
|
||||
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
|
||||
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
|
||||
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
|
||||
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
|
||||
|
||||
### Phase 2: Config Panel 통합 (2주)
|
||||
|
||||
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
|
||||
|
||||
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
|
||||
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
|
||||
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
|
||||
|
||||
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
|
||||
|
||||
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
|
||||
|
||||
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
|
||||
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
|
||||
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
|
||||
|
||||
### Phase 4: 안정화 및 마이그레이션 (2주)
|
||||
|
||||
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
|
||||
|
||||
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
|
||||
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
|
||||
- [ ] 마이그레이션 테스트 (스테이징 환경)
|
||||
- [ ] 문서화 및 개발 가이드 작성
|
||||
|
||||
### Phase 5: 레거시 정리 (추후 결정)
|
||||
|
||||
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
|
||||
|
||||
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
|
||||
- [ ] 미전환 화면 목록 정리
|
||||
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 마이그레이션 전략
|
||||
|
||||
### 5.1 위젯 타입 매핑 테이블
|
||||
|
||||
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
|
||||
|
||||
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
|
||||
| :-------------- | :------------ | :------------------------------ |
|
||||
| `text` | UnifiedInput | `type: "text"` |
|
||||
| `number` | UnifiedInput | `type: "number"` |
|
||||
| `email` | UnifiedInput | `type: "text", format: "email"` |
|
||||
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
|
||||
| `select` | UnifiedSelect | `mode: "dropdown"` |
|
||||
| `radio` | UnifiedSelect | `mode: "radio"` |
|
||||
| `checkbox` | UnifiedSelect | `mode: "check"` |
|
||||
| `date` | UnifiedDate | `type: "date"` |
|
||||
| `datetime` | UnifiedDate | `type: "datetime"` |
|
||||
| `textarea` | UnifiedText | `mode: "simple"` |
|
||||
| `file` | UnifiedMedia | `type: "file"` |
|
||||
| `image` | UnifiedMedia | `type: "image"` |
|
||||
|
||||
### 5.2 마이그레이션 원칙
|
||||
|
||||
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
|
||||
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
|
||||
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
|
||||
|
||||
---
|
||||
|
||||
## 6. 기대 효과
|
||||
|
||||
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
|
||||
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
|
||||
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
|
||||
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 및 대응 방안
|
||||
|
||||
| 리스크 | 영향도 | 대응 방안 |
|
||||
| :----------------------- | :----: | :-------------------------------- |
|
||||
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
|
||||
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
|
||||
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
|
||||
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 현재 컴포넌트 매핑 분석
|
||||
|
||||
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
|
||||
|
||||
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
|
||||
|
||||
#### UnifiedInput으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :------------- |
|
||||
| text-input | `type: "text"` | |
|
||||
| number-input | `type: "number"` | |
|
||||
| slider-basic | `type: "slider"` | 속성 추가 필요 |
|
||||
| button-primary | `type: "button"` | 별도 검토 |
|
||||
|
||||
#### UnifiedSelect로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------------ | :----------------------------------- | :------------- |
|
||||
| select-basic | `mode: "dropdown"` | |
|
||||
| checkbox-basic | `mode: "check"` | |
|
||||
| radio-basic | `mode: "radio"` | |
|
||||
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
|
||||
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
|
||||
| entity-search-input | `source: "entity"` | |
|
||||
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
|
||||
| location-swap-selector | `mode: "swap"` | 특수 UI |
|
||||
|
||||
#### UnifiedDate로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------- | :--- |
|
||||
| date-input | `type: "date"` | |
|
||||
|
||||
#### UnifiedText로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :--- |
|
||||
| textarea-basic | `mode: "simple"` | |
|
||||
|
||||
#### UnifiedMedia로 통합 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------------------------ | :--- |
|
||||
| file-upload | `type: "file"` | |
|
||||
| image-widget | `type: "image"` | |
|
||||
| image-display | `type: "image", readonly: true` | |
|
||||
|
||||
#### UnifiedList로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------------------ | :------------ |
|
||||
| table-list | `viewMode: "table"` | |
|
||||
| card-display | `viewMode: "card"` | |
|
||||
| repeater-field-group | `editable: true` | |
|
||||
| modal-repeater-table | `viewMode: "table", modal: true` | |
|
||||
| simple-repeater-table | `viewMode: "table", simple: true` | |
|
||||
| repeat-screen-modal | `viewMode: "card", modal: true` | |
|
||||
| table-search-widget | `viewMode: "table", searchable: true` | |
|
||||
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
|
||||
|
||||
#### UnifiedLayout으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------ | :-------------------------- | :------------- |
|
||||
| split-panel-layout | `type: "split"` | |
|
||||
| split-panel-layout2 | `type: "split", version: 2` | |
|
||||
| divider-line | `type: "divider"` | 속성 추가 필요 |
|
||||
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
|
||||
|
||||
#### UnifiedGroup으로 통합 (5개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------- | :--------------------- | :------------ |
|
||||
| accordion-basic | `type: "accordion"` | |
|
||||
| tabs | `type: "tabs"` | |
|
||||
| section-paper | `type: "section"` | |
|
||||
| section-card | `type: "card-section"` | |
|
||||
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
|
||||
|
||||
#### UnifiedBiz로 통합 (7개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------ | :--------------- |
|
||||
| flow-widget | `type: "flow"` | 플로우 관리 |
|
||||
| rack-structure | `type: "rack"` | 창고 렉 구조 |
|
||||
| map | `type: "map"` | 지도 |
|
||||
| numbering-rule | `type: "numbering"` | 채번 규칙 |
|
||||
| category-manager | `type: "category"` | 카테고리 관리 |
|
||||
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
|
||||
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
|
||||
|
||||
#### 별도 검토 필요 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 문제점 | 제안 |
|
||||
| :-------------------------- | :------------------- | :------------------------------ |
|
||||
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
|
||||
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
|
||||
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
|
||||
|
||||
### 8.2 매핑 분석 결과
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 전체 44개 컴포넌트 분석 결과 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ✅ 즉시 통합 가능 : 36개 (82%) │
|
||||
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
|
||||
│ 🔄 별도 검토 필요 : 3개 (7%) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 속성 확장 필요 사항
|
||||
|
||||
#### UnifiedInput 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "text" | "number" | "password";
|
||||
|
||||
// 확장
|
||||
type: "text" | "number" | "password" | "slider" | "color" | "button";
|
||||
```
|
||||
|
||||
#### UnifiedSelect 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
mode: "dropdown" | "radio" | "check" | "tag";
|
||||
|
||||
// 확장
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
```
|
||||
|
||||
#### UnifiedLayout 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "grid" | "split" | "flex";
|
||||
|
||||
// 확장
|
||||
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
|
||||
```
|
||||
|
||||
### 8.4 조건부 렌더링 공통화
|
||||
|
||||
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
|
||||
|
||||
```typescript
|
||||
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
|
||||
interface BaseUnifiedProps {
|
||||
// ... 기존 속성
|
||||
|
||||
/** 조건부 렌더링 설정 */
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string; // 참조할 필드명
|
||||
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||
value: any; // 비교 값
|
||||
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
|
||||
|
||||
### 9.1 현재 계층 구조 지원 현황
|
||||
|
||||
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| :----------------- | :---------------------- | :--------------- |
|
||||
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
|
||||
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
|
||||
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
|
||||
| **TREE** | 일반 트리 | 카테고리 |
|
||||
|
||||
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
|
||||
|
||||
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
|
||||
|
||||
```typescript
|
||||
interface UnifiedHierarchyProps {
|
||||
/** 계층 유형 */
|
||||
type: "tree" | "org" | "bom" | "cascading";
|
||||
|
||||
/** 표시 방식 */
|
||||
viewMode: "tree" | "table" | "indent" | "dropdown";
|
||||
|
||||
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
|
||||
source: string;
|
||||
|
||||
/** 편집 가능 여부 */
|
||||
editable?: boolean;
|
||||
|
||||
/** 드래그 정렬 가능 */
|
||||
draggable?: boolean;
|
||||
|
||||
/** BOM 수량 표시 (BOM 타입 전용) */
|
||||
showQty?: boolean;
|
||||
|
||||
/** 최대 레벨 제한 */
|
||||
maxLevel?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 활용 예시
|
||||
|
||||
| 설정 | 결과 |
|
||||
| :---------------------------------------- | :------------------------- |
|
||||
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
|
||||
| `type: "org", viewMode: "tree"` | 조직도 |
|
||||
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
|
||||
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 최종 통합 컴포넌트 목록 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 | 커버 범위 |
|
||||
| :-: | :------------------- | :------------- | :----------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
|
||||
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
|
||||
|
||||
---
|
||||
|
||||
## 11. 연쇄관계 관리 메뉴 통합 전략
|
||||
|
||||
### 11.1 현재 연쇄관계 관리 현황
|
||||
|
||||
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
|
||||
|
||||
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
|
||||
| :--------------- | :--------------------------------------- | :---------: | :----: |
|
||||
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
|
||||
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
|
||||
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
|
||||
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
|
||||
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
|
||||
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
|
||||
|
||||
### 11.2 통합 방향: 속성 기반 vs 공통 정의
|
||||
|
||||
#### 판단 기준
|
||||
|
||||
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
|
||||
| :--------------- | :---------: | :---------: | :----------------------- |
|
||||
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
|
||||
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
|
||||
|
||||
### 11.3 속성 통합 설계
|
||||
|
||||
#### 2단계 연쇄 → UnifiedSelect 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
|
||||
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
source="db"
|
||||
table="warehouse_location"
|
||||
valueColumn="location_code"
|
||||
labelColumn="location_name"
|
||||
cascading={{
|
||||
parentField: "warehouse_code", // 같은 화면 내 부모 필드
|
||||
filterColumn: "warehouse_code", // 필터링할 컬럼
|
||||
clearOnChange: true // 부모 변경 시 초기화
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 조건부 필터 → 공통 conditional 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 조건 정의
|
||||
// cascading_condition 테이블에 저장
|
||||
|
||||
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
|
||||
<UnifiedInput
|
||||
conditional={{
|
||||
enabled: true,
|
||||
field: "order_type", // 참조할 필드
|
||||
operator: "=", // 비교 연산자
|
||||
value: "EXPORT", // 비교 값
|
||||
action: "show", // show | hide | disable | enable
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 자동 입력 → autoFill 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_auto_fill_group 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedInput
|
||||
autoFill={{
|
||||
enabled: true,
|
||||
sourceTable: "company_mng", // 조회할 테이블
|
||||
filterColumn: "company_code", // 필터링 컬럼
|
||||
userField: "companyCode", // 사용자 정보 필드
|
||||
displayColumn: "company_name", // 표시할 컬럼
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 상호 배제 → mutualExclusion 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_mutual_exclusion 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
mutualExclusion={{
|
||||
enabled: true,
|
||||
targetField: "sub_category", // 상호 배제 대상 필드
|
||||
type: "exclusive", // exclusive | inclusive
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 11.4 관리 메뉴 정리 계획
|
||||
|
||||
| 현재 메뉴 | TO-BE | 비고 |
|
||||
| :-------------------------- | :----------------------- | :-------------------- |
|
||||
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
|
||||
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
|
||||
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
|
||||
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
|
||||
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
|
||||
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
|
||||
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
|
||||
|
||||
### 11.5 DB 테이블 정리 (Phase 5)
|
||||
|
||||
| 테이블 | 조치 | 시점 |
|
||||
| :--------------------------- | :----------------------- | :------ |
|
||||
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
|
||||
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_hierarchy_*` | **유지** | - |
|
||||
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
|
||||
|
||||
### 11.6 마이그레이션 스크립트 필요 항목
|
||||
|
||||
```sql
|
||||
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
|
||||
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
|
||||
-- 해당 컴포넌트의 cascading 속성으로 변환
|
||||
|
||||
-- 예시: WAREHOUSE_LOCATION 연쇄관계
|
||||
-- 이 관계를 사용하는 화면의 컴포넌트에
|
||||
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
|
||||
-- 속성 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 최종 아키텍처 요약
|
||||
|
||||
### 12.1 통합 컴포넌트 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 |
|
||||
| :-: | :------------------- | :--------------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
|
||||
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
|
||||
|
||||
### 12.2 공통 속성 (모든 컴포넌트에 적용)
|
||||
|
||||
```typescript
|
||||
interface BaseUnifiedProps {
|
||||
// 기본 속성
|
||||
id: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// 스타일
|
||||
style?: ComponentStyle;
|
||||
className?: string;
|
||||
|
||||
// 조건부 렌더링 (conditional-container 대체)
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string;
|
||||
operator:
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "isEmpty"
|
||||
| "isNotEmpty";
|
||||
value: any;
|
||||
action: "show" | "hide" | "disable" | "enable";
|
||||
};
|
||||
|
||||
// 자동 입력 (autoFill 대체)
|
||||
autoFill?: {
|
||||
enabled: boolean;
|
||||
sourceTable: string;
|
||||
filterColumn: string;
|
||||
userField: "companyCode" | "userId" | "deptCode";
|
||||
displayColumn: string;
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
validation?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 UnifiedSelect 전용 속성
|
||||
|
||||
```typescript
|
||||
interface UnifiedSelectProps extends BaseUnifiedProps {
|
||||
// 표시 모드
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
|
||||
// 데이터 소스
|
||||
source: "static" | "code" | "db" | "api" | "entity";
|
||||
|
||||
// static 소스
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
|
||||
// db 소스
|
||||
table?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
|
||||
// code 소스
|
||||
codeGroup?: string;
|
||||
|
||||
// 연쇄 관계 (cascading_relation 대체)
|
||||
cascading?: {
|
||||
parentField: string; // 부모 필드명
|
||||
filterColumn: string; // 필터링할 컬럼
|
||||
clearOnChange?: boolean; // 부모 변경 시 초기화
|
||||
};
|
||||
|
||||
// 상호 배제 (mutual_exclusion 대체)
|
||||
mutualExclusion?: {
|
||||
enabled: boolean;
|
||||
targetField: string; // 상호 배제 대상
|
||||
type: "exclusive" | "inclusive";
|
||||
};
|
||||
|
||||
// 다중 선택
|
||||
multiple?: boolean;
|
||||
maxSelect?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 12.4 관리 메뉴 정리 결과
|
||||
|
||||
| AS-IS | TO-BE |
|
||||
| :---------------------------- | :----------------------------------- |
|
||||
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
|
||||
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
|
||||
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
|
||||
| - 조건부 필터 | → 공통 conditional 속성 |
|
||||
| - 자동 입력 | → 공통 autoFill 속성 |
|
||||
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
|
||||
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 주의사항
|
||||
|
||||
> **기존 컴포넌트 삭제 금지**
|
||||
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
|
||||
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
|
||||
|
||||
> **연쇄관계 마이그레이션 필수**
|
||||
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
|
||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,58 @@
|
|||
# 프로젝트 진행 상황 (2025-11-20)
|
||||
|
||||
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
|
||||
|
||||
### 1. 핵심 변경 사항
|
||||
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
|
||||
|
||||
### 2. 완료된 작업
|
||||
|
||||
#### 데이터베이스
|
||||
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
- **스키마 변경**:
|
||||
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
|
||||
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
|
||||
- 기존 하드코딩된 테이블 매핑 컬럼 제거
|
||||
|
||||
#### 백엔드 (Node.js)
|
||||
- **API 추가/수정**:
|
||||
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
|
||||
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
|
||||
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
|
||||
- **컨트롤러 수정**:
|
||||
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
|
||||
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
|
||||
|
||||
#### 프론트엔드 (React)
|
||||
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
|
||||
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
|
||||
- **유틸리티**: `spatialContainment.ts`
|
||||
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
|
||||
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
|
||||
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
|
||||
- `HierarchyConfigPanel` 적용
|
||||
- 동적 데이터 로드 로직 구현
|
||||
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
|
||||
- 객체 이동 시 그룹 이동 적용
|
||||
|
||||
### 3. 현재 상태
|
||||
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
|
||||
- **DB**: 마이그레이션 스크립트 실행 완료
|
||||
|
||||
### 4. 다음 단계 (테스트 필요)
|
||||
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
|
||||
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
|
||||
2. **배치 검증**:
|
||||
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
|
||||
- 위치를 구역 **외부**에 배치 (실패해야 함)
|
||||
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
|
||||
|
||||
### 5. 관련 파일
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
|
||||
- `backend-node/src/controllers/digitalTwinDataController.ts`
|
||||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
- 기존 모달 기반 필드 매핑을 메인 화면으로 통합
|
||||
- 중복된 테이블 선택 과정 제거
|
||||
- 시각적 필드 연결 매핑 구현
|
||||
- 좌우 분할 레이아웃으로 정보 가시성 향상
|
||||
|
||||
### 현재 문제점
|
||||
|
||||
- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택)
|
||||
- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐
|
||||
- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음
|
||||
- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음
|
||||
|
||||
## 🎯 새로운 UI 구조
|
||||
|
||||
### 레이아웃 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 제어관리 - 데이터 연결 설정 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 좌측 패널 (30%) │ 우측 패널 (70%) │
|
||||
│ - 연결 타입 선택 │ - 단계별 설정 UI │
|
||||
│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │
|
||||
│ - 상세 설정 목록 │ - 실시간 연결선 표시 │
|
||||
│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 기본 구조 구축
|
||||
|
||||
- [ ] 좌우 분할 레이아웃 컴포넌트 생성
|
||||
- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링
|
||||
- [ ] 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
### Phase 2: 좌측 패널 구현
|
||||
|
||||
- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출)
|
||||
- [ ] 실시간 매핑 정보 표시
|
||||
- [ ] 매핑 상세 목록 컴포넌트
|
||||
- [ ] 고급 설정 패널
|
||||
|
||||
### Phase 3: 우측 패널 구현
|
||||
|
||||
- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑)
|
||||
- [ ] 시각적 필드 매핑 영역
|
||||
- [ ] SVG 기반 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑 기능
|
||||
|
||||
### Phase 4: 고급 기능
|
||||
|
||||
- [ ] 실시간 검증 및 피드백
|
||||
- [ ] 매핑 미리보기 기능
|
||||
- [ ] 설정 저장/불러오기
|
||||
- [ ] 테스트 실행 기능
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
### 새로 생성할 컴포넌트
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/redesigned/
|
||||
├── DataConnectionDesigner.tsx # 메인 컨테이너
|
||||
├── LeftPanel/
|
||||
│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택
|
||||
│ ├── MappingInfoPanel.tsx # 매핑 정보 표시
|
||||
│ ├── MappingDetailList.tsx # 매핑 상세 목록
|
||||
│ ├── AdvancedSettings.tsx # 고급 설정
|
||||
│ └── ActionButtons.tsx # 액션 버튼들
|
||||
├── RightPanel/
|
||||
│ ├── StepProgress.tsx # 단계 진행 표시
|
||||
│ ├── ConnectionStep.tsx # 1단계: 연결 선택
|
||||
│ ├── TableStep.tsx # 2단계: 테이블 선택
|
||||
│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑
|
||||
│ └── VisualMapping/
|
||||
│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스
|
||||
│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트
|
||||
│ ├── ConnectionLine.tsx # SVG 연결선
|
||||
│ └── MappingControls.tsx # 매핑 제어 도구
|
||||
└── types/
|
||||
└── redesigned.ts # 타입 정의
|
||||
```
|
||||
|
||||
### 수정할 기존 파일
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/
|
||||
├── DataSaveSettings.tsx # 새 UI로 교체
|
||||
├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
└── ActionFieldMappings.tsx # 레거시 처리
|
||||
```
|
||||
|
||||
## 🎨 UI 컴포넌트 상세
|
||||
|
||||
### 1. 연결 타입 선택 (ConnectionTypeSelector)
|
||||
|
||||
```typescript
|
||||
interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. 시각적 필드 매핑 (FieldMappingCanvas)
|
||||
|
||||
```typescript
|
||||
interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 매핑 정보 패널 (MappingInfoPanel)
|
||||
|
||||
```typescript
|
||||
interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 데이터 플로우
|
||||
|
||||
### 상태 관리
|
||||
|
||||
```typescript
|
||||
interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
```
|
||||
|
||||
### 이벤트 핸들링
|
||||
|
||||
```typescript
|
||||
interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 사용자 경험 (UX) 개선점
|
||||
|
||||
### Before (기존)
|
||||
|
||||
1. 테이블 더블클릭 → 화면에 표시
|
||||
2. 모달 열기 → 다시 테이블 선택
|
||||
3. 외부 커넥션 설정 → 또 다시 테이블 선택
|
||||
4. 필드 매핑 → 텍스트 기반 매핑
|
||||
|
||||
### After (개선)
|
||||
|
||||
1. **연결 타입 선택** → 목적 명확화
|
||||
2. **연결 선택** → 한 번에 FROM/TO 설정
|
||||
3. **테이블 선택** → 즉시 필드 정보 로드
|
||||
4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### 🔥 High Priority
|
||||
|
||||
1. **기본 레이아웃** - 좌우 분할 구조
|
||||
2. **연결 타입 선택** - 데이터 저장/외부 호출
|
||||
3. **단계별 진행** - 연결 → 테이블 → 매핑
|
||||
4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반
|
||||
|
||||
### 🔶 Medium Priority
|
||||
|
||||
1. **시각적 연결선** - SVG 기반 라인 표시
|
||||
2. **실시간 검증** - 타입 호환성 체크
|
||||
3. **매핑 정보 패널** - 통계 및 상태 표시
|
||||
4. **드래그 앤 드롭** - 고급 매핑 기능
|
||||
|
||||
### 🔵 Low Priority
|
||||
|
||||
1. **고급 설정** - 트랜잭션, 배치 설정
|
||||
2. **미리보기 기능** - 데이터 변환 미리보기
|
||||
3. **설정 템플릿** - 자주 사용하는 매핑 저장
|
||||
4. **성능 최적화** - 대용량 테이블 처리
|
||||
|
||||
## 📅 개발 일정
|
||||
|
||||
### Week 1: 기본 구조
|
||||
|
||||
- [ ] 레이아웃 컴포넌트 생성
|
||||
- [ ] 연결 타입 선택 구현
|
||||
- [ ] 기존 컴포넌트 리팩토링
|
||||
|
||||
### Week 2: 핵심 기능
|
||||
|
||||
- [ ] 단계별 진행 UI
|
||||
- [ ] 연결/테이블 선택 통합
|
||||
- [ ] 기본 필드 매핑 구현
|
||||
|
||||
### Week 3: 시각적 개선
|
||||
|
||||
- [ ] SVG 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑
|
||||
- [ ] 실시간 검증 기능
|
||||
|
||||
### Week 4: 완성 및 테스트
|
||||
|
||||
- [ ] 고급 기능 구현
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 테스트 및 피드백 반영
|
||||
|
||||
## 🔍 기술적 고려사항
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- **가상화**: 대용량 필드 목록 처리
|
||||
- **메모이제이션**: 불필요한 리렌더링 방지
|
||||
- **지연 로딩**: 필요한 시점에만 데이터 로드
|
||||
|
||||
### 접근성
|
||||
|
||||
- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능
|
||||
- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공
|
||||
- **색상 대비**: 연결선과 상태 표시의 명확한 구분
|
||||
|
||||
### 확장성
|
||||
|
||||
- **플러그인 구조**: 새로운 연결 타입 쉽게 추가
|
||||
- **커스텀 변환**: 사용자 정의 데이터 변환 규칙
|
||||
- **API 확장**: 외부 시스템과의 연동 지원
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다.
|
||||
|
||||
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
구현을 시작하시겠어요? 🚀
|
||||
196
UI_개선사항_문서.md
196
UI_개선사항_문서.md
|
|
@ -601,4 +601,200 @@ export default function EmptyStatePage() {
|
|||
|
||||
---
|
||||
|
||||
## 📧 메일 관리 시스템 UI 개선사항
|
||||
|
||||
### 최근 업데이트 (2025-01-02)
|
||||
|
||||
#### 1. 메일 발송 페이지 헤더 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-lg">
|
||||
<Send className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">메일 발송</h1>
|
||||
<p className="text-sm text-gray-500">설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**변경 후 (표준 헤더 카드 적용):**
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">메일 발송</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ 불필요한 아이콘 제거 (종이비행기)
|
||||
- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
|
||||
- ✅ 다른 페이지와 동일한 헤더 스타일 적용
|
||||
|
||||
#### 2. 메일 내용 입력 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<Textarea placeholder="메일 내용을 html로 작성하세요" />
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```tsx
|
||||
<Textarea
|
||||
placeholder="메일 내용을 입력하세요
|
||||
|
||||
줄바꿈은 자동으로 처리됩니다."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
||||
</p>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ HTML 지식 없이도 사용 가능
|
||||
- ✅ 일반 텍스트 입력 후 자동 HTML 변환
|
||||
- ✅ 사용자 친화적인 안내 메시지
|
||||
|
||||
#### 3. CC/BCC 기능 추가
|
||||
**구현 내용:**
|
||||
```tsx
|
||||
{/* To 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={to}
|
||||
onTagsChange={setTo}
|
||||
placeholder="받는 사람 이메일"
|
||||
/>
|
||||
|
||||
{/* CC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={cc}
|
||||
onTagsChange={setCc}
|
||||
placeholder="참조 (선택사항)"
|
||||
/>
|
||||
|
||||
{/* BCC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={bcc}
|
||||
onTagsChange={setBcc}
|
||||
placeholder="숨은참조 (선택사항)"
|
||||
/>
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- ✅ 이메일 주소를 태그 형태로 시각화
|
||||
- ✅ 쉼표로 구분하여 입력 가능
|
||||
- ✅ 개별 삭제 가능
|
||||
|
||||
#### 4. 파일 첨부 기능 (Phase 1 완료)
|
||||
|
||||
**백엔드 구현:**
|
||||
```typescript
|
||||
// multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 발송 API
|
||||
router.post(
|
||||
'/simple',
|
||||
uploadMailAttachment.array('attachments', 5),
|
||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
**보안 기능:**
|
||||
- ✅ 위험한 파일 확장자 차단 (.exe, .bat, .cmd, .sh 등)
|
||||
- ✅ 파일 크기 제한 (10MB)
|
||||
- ✅ 파일 개수 제한 (최대 5개)
|
||||
- ✅ 안전한 파일명 생성
|
||||
|
||||
**프론트엔드 구현 예정 (Phase 1-3):**
|
||||
- 드래그 앤 드롭 파일 업로드
|
||||
- 첨부된 파일 목록 표시
|
||||
- 파일 삭제 기능
|
||||
- 미리보기에 첨부파일 정보 표시
|
||||
|
||||
#### 5. 향후 작업 계획
|
||||
|
||||
**Phase 2: 보낸메일함 백엔드**
|
||||
- 발송 이력 자동 저장 (JSON 파일)
|
||||
- 발송 상태 관리 (성공/실패)
|
||||
- 발송 이력 조회 API
|
||||
|
||||
**Phase 3: 보낸메일함 프론트엔드**
|
||||
- `/admin/mail/sent` 페이지
|
||||
- 발송 목록 테이블
|
||||
- 상세보기 모달
|
||||
- 재전송 기능
|
||||
|
||||
**Phase 4: 대시보드 통합**
|
||||
- 대시보드에 "보낸메일함" 링크
|
||||
- 실제 발송 통계 연동
|
||||
- 최근 활동 목록
|
||||
|
||||
### 메일 시스템 UI 가이드
|
||||
|
||||
#### 이메일 태그 입력
|
||||
```tsx
|
||||
// 이메일 주소를 시각적으로 표시
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md text-sm"
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
{tag}
|
||||
<button onClick={() => removeTag(index)}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 파일 첨부 영역 (예정)
|
||||
```tsx
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-orange-400 transition-colors">
|
||||
<input type="file" multiple className="hidden" />
|
||||
<div className="text-center">
|
||||
<Upload className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
최대 5개, 각 10MB 이하
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 발송 성공 토스트
|
||||
```tsx
|
||||
<div className="fixed top-4 right-4 bg-white rounded-lg shadow-lg border border-green-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일이 발송되었습니다</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{to.length}명에게 전송 완료
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ====================
|
||||
# 옵션: file | database | memory
|
||||
# - file: 파일 기반 (빠른 개발/테스트)
|
||||
# - database: PostgreSQL DB (실제 운영)
|
||||
# - memory: 메모리 목 데이터 (테스트)
|
||||
|
||||
TODO_DATA_SOURCE=file
|
||||
BOOKING_DATA_SOURCE=file
|
||||
MAINTENANCE_DATA_SOURCE=memory
|
||||
DOCUMENT_DATA_SOURCE=memory
|
||||
|
||||
|
||||
# OpenWeatherMap API 키 추가 (실시간 날씨)
|
||||
# https://openweathermap.org/api 에서 무료 가입 후 발급
|
||||
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
|
||||
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 🔑 공유 API 키 (팀 전체 사용)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||
# 팀원들이 동일한 API 키를 사용합니다.
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# 한국은행 환율 API 키
|
||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
||||
|
||||
# 기상청 API Hub 키
|
||||
# 발급: https://apihub.kma.go.kr/
|
||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||
|
||||
# ITS 국가교통정보센터 API 키
|
||||
# 발급: https://www.its.go.kr/
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
|
||||
# 한국도로공사 OpenOASIS API 키
|
||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# ExchangeRate API 키 (백업용, 선택사항)
|
||||
# 발급: https://www.exchangerate-api.com/
|
||||
# EXCHANGERATE_API_KEY=your_exchangerate_api_key_here
|
||||
|
||||
# Kakao API 키 (Geocoding용, 선택사항)
|
||||
# 발급: https://developers.kakao.com/
|
||||
# KAKAO_API_KEY=your_kakao_api_key_here
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 📝 사용 방법
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# 1. 이 파일을 복사하여 .env 파일 생성:
|
||||
# $ cp .env.shared .env
|
||||
#
|
||||
# 2. 그대로 사용하면 됩니다!
|
||||
# (팀 전체가 동일한 키 사용)
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
|
||||
# 🔌 API 연동 가이드
|
||||
|
||||
## 📊 현재 상태
|
||||
|
||||
### ✅ 작동 중인 API
|
||||
|
||||
1. **기상청 특보 API** (완벽 작동!)
|
||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||
|
||||
2. **한국은행 환율 API** (완벽 작동!)
|
||||
- API 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
- 상태: ✅ 환율 위젯 작동 중
|
||||
|
||||
### ⚠️ 더미 데이터 사용 중
|
||||
|
||||
3. **교통사고 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
4. **도로공사 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실시간 교통정보 연동하기
|
||||
|
||||
### 📌 국토교통부 ITS API (추천!)
|
||||
|
||||
#### 1단계: API 신청
|
||||
1. https://www.data.go.kr/ 접속
|
||||
2. 검색: **"ITS 돌발정보"** 또는 **"실시간 교통정보"**
|
||||
3. **활용신청** 클릭
|
||||
4. **승인 대기 (1~2일)**
|
||||
|
||||
#### 2단계: API 키 추가
|
||||
승인 완료되면 `.env` 파일에 추가:
|
||||
|
||||
```env
|
||||
# 국토교통부 ITS API 키
|
||||
ITS_API_KEY=발급받은_API_키
|
||||
```
|
||||
|
||||
#### 3단계: 서버 재시작
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
#### 4단계: 확인
|
||||
- 로그에서 `✅ 국토교통부 ITS 교통사고 API 응답 수신 완료` 확인
|
||||
- 더미 데이터 대신 실제 데이터가 표시됨!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 한국도로공사 API 문제
|
||||
|
||||
### 발급된 키
|
||||
```
|
||||
EXWAY_API_KEY=7820214492
|
||||
```
|
||||
|
||||
### 문제 상황
|
||||
- ❌ 서버/백엔드에서 호출 시: `Request Blocked` (400)
|
||||
- ❌ curl 명령어: `Request Blocked`
|
||||
- ❌ 모든 엔드포인트 차단됨
|
||||
|
||||
### 가능한 원인
|
||||
1. **브라우저에서만 접근 허용**
|
||||
- Referer 헤더 검증
|
||||
- User-Agent 검증
|
||||
|
||||
2. **IP 화이트리스트**
|
||||
- 특정 IP에서만 접근 가능
|
||||
- 서버 IP 등록 필요
|
||||
|
||||
3. **API 키 활성화 대기**
|
||||
- 발급 후 승인 대기 중
|
||||
- 몇 시간~1일 소요
|
||||
|
||||
### 해결 방법
|
||||
1. 한국도로공사 담당자 문의 (054-811-4533)
|
||||
2. 국토교통부 ITS API 사용 (더 안정적)
|
||||
|
||||
---
|
||||
|
||||
## 📝 코드 구조
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
// 1순위: 국토교통부 ITS API
|
||||
if (process.env.ITS_API_KEY) {
|
||||
try {
|
||||
// ITS API 호출
|
||||
return itsData;
|
||||
} catch {
|
||||
console.log('2순위 API로 전환');
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 한국도로공사 API
|
||||
try {
|
||||
// 한국도로공사 API 호출
|
||||
return exwayData;
|
||||
} catch {
|
||||
console.log('더미 데이터 사용');
|
||||
}
|
||||
|
||||
// 3순위: 더미 데이터
|
||||
return dummyData;
|
||||
```
|
||||
|
||||
### 파일 위치
|
||||
- 서비스: `backend-node/src/services/riskAlertService.ts`
|
||||
- 컨트롤러: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- 라우트: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
---
|
||||
|
||||
## 💡 현재 대시보드 위젯 데이터
|
||||
|
||||
### 리스크/알림 위젯
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
─────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
### 개선 후 (ITS API 연동 시)
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
✅ 교통사고: N건 (실제 ITS 데이터)
|
||||
✅ 도로공사: N건 (실제 ITS 데이터)
|
||||
─────────────────────────
|
||||
총 N건의 알림 (모두 실시간!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] 국토교통부 ITS API 신청
|
||||
|
||||
### 장기 (향후)
|
||||
- [ ] 서울시 TOPIS API 추가 (서울시 교통정보)
|
||||
- [ ] 경찰청 교통사고 정보 API (승인 필요)
|
||||
- [ ] 기상청 단기예보 API 추가
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의
|
||||
|
||||
### 한국도로공사
|
||||
- 전화: 054-811-4533 (컨텐츠 문의)
|
||||
- 전화: 070-8656-8771 (시스템 장애)
|
||||
|
||||
### 공공데이터포털
|
||||
- 웹사이트: https://www.data.go.kr/
|
||||
- 고객센터: 1661-0423
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 기상청 특보 작동 중, ITS API 연동 준비 완료
|
||||
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
|
||||
# 🔑 API 키 현황 및 연동 상태
|
||||
|
||||
## ✅ 완벽 작동 중
|
||||
|
||||
### 1. 기상청 API Hub
|
||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||
|
||||
### 2. 한국은행 환율 API
|
||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
||||
- **상태**: ✅ 환율 위젯 작동 중
|
||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 연동 대기 중
|
||||
|
||||
### 3. 한국도로공사 OpenOASIS API
|
||||
- **API 키**: `7820214492`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **문제**:
|
||||
- 발급 이메일에 사용법 없음
|
||||
- 매뉴얼에 상세 정보 없음
|
||||
- 테스트한 URL 모두 실패
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 한국도로공사 고객센터 문의
|
||||
|
||||
컨텐츠 문의: 054-811-4533
|
||||
시스템 장애: 070-8656-8771
|
||||
|
||||
문의 내용:
|
||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||
- 돌발상황정보 API
|
||||
- 교통사고 정보
|
||||
- 도로공사 정보"
|
||||
```
|
||||
|
||||
### 4. 국토교통부 ITS API
|
||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **승인 API**:
|
||||
- 교통소통정보
|
||||
- 돌발상황정보
|
||||
- CCTV 화상자료
|
||||
- 교통예측정보
|
||||
- 차량검지정보
|
||||
- 도로전광표지(VMS)
|
||||
- 주의운전구간
|
||||
- 가변형 속도제한표지(VSL)
|
||||
- 위험물질 운송차량 사고정보
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 ITS 국가교통정보센터 문의
|
||||
|
||||
전화: 1577-6782
|
||||
이메일: its@ex.co.kr
|
||||
|
||||
문의 내용:
|
||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||
알려주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 백엔드 연동 준비 완료
|
||||
|
||||
### 파일 위치
|
||||
- **서비스**: `backend-node/src/services/riskAlertService.ts`
|
||||
- **컨트롤러**: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- **라우트**: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
1순위: 국토교통부 ITS API (process.env.ITS_API_KEY)
|
||||
2순위: 한국도로공사 API (process.env.EXWAY_API_KEY)
|
||||
3순위: 더미 데이터 (현실적인 예시)
|
||||
```
|
||||
|
||||
### 연동 방법
|
||||
```bash
|
||||
# .env 파일에 추가
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# 백엔드 재시작
|
||||
docker restart pms-backend-mac
|
||||
|
||||
# 로그 확인
|
||||
docker logs pms-backend-mac --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 현재 리스크/알림 시스템
|
||||
|
||||
```
|
||||
✅ 기상특보: 14건 (실시간 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
────────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] ITS/한국도로공사 API 키 발급 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] **API 엔드포인트 URL 확인 (고객센터 문의)**
|
||||
|
||||
### 중기 (API URL 확인 후)
|
||||
- [ ] ITS API 연동 (즉시 가능)
|
||||
- [ ] 한국도로공사 API 연동 (즉시 가능)
|
||||
- [ ] 실시간 교통사고 데이터 표시
|
||||
- [ ] 실시간 도로공사 데이터 표시
|
||||
|
||||
### 장기 (추가 기능)
|
||||
- [ ] 서울시 TOPIS API 추가
|
||||
- [ ] CCTV 화상 자료 연동
|
||||
- [ ] 도로전광표지(VMS) 정보
|
||||
- [ ] 교통예측정보
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**상태**: 기상청 특보 작동 중, 교통정보 API URL 확인 필요
|
||||
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# 🔑 API 키 설정 가이드
|
||||
|
||||
## 빠른 시작 (신규 팀원용)
|
||||
|
||||
### 1. API 키 파일 복사
|
||||
```bash
|
||||
cd backend-node
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### 2. 끝!
|
||||
- `.env.shared` 파일에 **팀 공유 API 키**가 이미 들어있습니다
|
||||
- 그대로 복사해서 사용하면 됩니다
|
||||
- 추가 발급 필요 없음!
|
||||
|
||||
---
|
||||
|
||||
## 📋 포함된 API 키
|
||||
|
||||
### ✅ 한국은행 환율 API
|
||||
- 용도: 환율 정보 조회
|
||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
|
||||
### ✅ 기상청 API Hub
|
||||
- 용도: 날씨특보, 기상정보
|
||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
|
||||
### ✅ ITS 국가교통정보센터
|
||||
- 용도: 교통사고, 도로공사 정보
|
||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
||||
|
||||
### ✅ 한국도로공사 OpenOASIS
|
||||
- 용도: 고속도로 교통정보
|
||||
- 키: `7820214492`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### Git 관리
|
||||
```bash
|
||||
✅ .env.shared → Git에 커밋됨 (팀 공유용)
|
||||
❌ .env → Git에 커밋 안 됨 (개인 설정)
|
||||
```
|
||||
|
||||
### 보안
|
||||
- **팀 내부 프로젝트**이므로 키 공유가 안전합니다
|
||||
- 외부 공개 프로젝트라면 각자 발급받아야 합니다
|
||||
|
||||
---
|
||||
|
||||
## 🚀 서버 시작
|
||||
|
||||
```bash
|
||||
# 1. API 키 설정 (최초 1회만)
|
||||
cp .env.shared .env
|
||||
|
||||
# 2. 서버 시작
|
||||
npm run dev
|
||||
|
||||
# 또는 Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 트러블슈팅
|
||||
|
||||
### `.env` 파일이 없다는 오류
|
||||
```bash
|
||||
# 해결: .env.shared를 복사
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### API 호출이 실패함
|
||||
```bash
|
||||
# 1. .env 파일 확인
|
||||
cat .env
|
||||
|
||||
# 2. API 키가 제대로 복사되었는지 확인
|
||||
# 3. 서버 재시작
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**팀원 여러분, `.env.shared`를 복사해서 사용하세요!** 👍
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
[
|
||||
{
|
||||
"id": "773568c7-0fc8-403d-ace2-01a11fae7189",
|
||||
"customerName": "김철수",
|
||||
"customerPhone": "010-1234-5678",
|
||||
"pickupLocation": "서울시 강남구 역삼동 123",
|
||||
"dropoffLocation": "경기도 성남시 분당구 정자동 456",
|
||||
"scheduledTime": "2025-10-14T10:03:32.556Z",
|
||||
"vehicleType": "truck",
|
||||
"cargoType": "전자제품",
|
||||
"weight": 500,
|
||||
"status": "accepted",
|
||||
"priority": "urgent",
|
||||
"createdAt": "2025-10-14T08:03:32.556Z",
|
||||
"updatedAt": "2025-10-14T08:06:45.073Z",
|
||||
"estimatedCost": 150000,
|
||||
"acceptedAt": "2025-10-14T08:06:45.073Z"
|
||||
},
|
||||
{
|
||||
"id": "0751b297-18df-42c0-871c-85cded1f6dae",
|
||||
"customerName": "이영희",
|
||||
"customerPhone": "010-9876-5432",
|
||||
"pickupLocation": "서울시 송파구 잠실동 789",
|
||||
"dropoffLocation": "인천시 남동구 구월동 321",
|
||||
"scheduledTime": "2025-10-14T12:03:32.556Z",
|
||||
"vehicleType": "van",
|
||||
"cargoType": "가구",
|
||||
"weight": 300,
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"createdAt": "2025-10-14T07:53:32.556Z",
|
||||
"updatedAt": "2025-10-14T07:53:32.556Z",
|
||||
"estimatedCost": 80000
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,80 @@
|
|||
[
|
||||
{
|
||||
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
|
||||
"title": "연동되어주려무니",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "in_progress",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-21T15:21",
|
||||
"createdAt": "2025-10-20T06:21:19.817Z",
|
||||
"updatedAt": "2025-10-20T09:00:26.948Z",
|
||||
"isUrgent": false,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "c8292b4d-bb45-487c-aa29-55b78580b837",
|
||||
"title": "오늘의 힐일",
|
||||
"description": "이거 데이터베이스랑 연결하기",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-23T14:04",
|
||||
"createdAt": "2025-10-23T05:04:50.249Z",
|
||||
"updatedAt": "2025-10-23T05:04:50.249Z",
|
||||
"isUrgent": false,
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"id": "2c7f90a3-947c-4693-8525-7a2a707172c0",
|
||||
"title": "테스트용 일정",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-16T18:16",
|
||||
"createdAt": "2025-10-23T05:13:14.076Z",
|
||||
"updatedAt": "2025-10-23T05:13:14.076Z",
|
||||
"isUrgent": false,
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"id": "499feff6-92c7-45a9-91fa-ca727edf90f2",
|
||||
"title": "ㅁSdf",
|
||||
"description": "asdfsdfs",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:15:38.430Z",
|
||||
"updatedAt": "2025-10-23T05:15:38.430Z",
|
||||
"isUrgent": false,
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"id": "166c3910-9908-457f-8c72-8d0183f12e2f",
|
||||
"title": "ㅎㄹㅇㄴ",
|
||||
"description": "ㅎㄹㅇㄴ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:01.515Z",
|
||||
"updatedAt": "2025-10-23T05:21:01.515Z",
|
||||
"isUrgent": false,
|
||||
"order": 7
|
||||
},
|
||||
{
|
||||
"id": "bfa9d476-bb98-41d5-9d74-b016be011bba",
|
||||
"title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ",
|
||||
"description": "ㅁㄴㅇㄹㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:25.781Z",
|
||||
"updatedAt": "2025-10-23T05:21:25.781Z",
|
||||
"isUrgent": false,
|
||||
"order": 8
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts,json",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "node -r ts-node/register/transpile-only src/app.ts"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -26,12 +26,16 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
@ -40,14 +44,19 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
@ -60,6 +69,7 @@
|
|||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* 외부 DB 연결 정보 추가 스크립트
|
||||
* 비밀번호를 암호화하여 안전하게 저장
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function addExternalDbConnection() {
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
// 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용)
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
// 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력)
|
||||
const externalDbConnections = [
|
||||
{
|
||||
name: "운영_외부_PostgreSQL",
|
||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||
dbType: "postgresql",
|
||||
host: "39.117.244.52",
|
||||
port: 11132,
|
||||
databaseName: "plm",
|
||||
username: "postgres",
|
||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
},
|
||||
// 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가
|
||||
// {
|
||||
// name: "테스트_MySQL",
|
||||
// description: "테스트용 MySQL 데이터베이스",
|
||||
// dbType: "mysql",
|
||||
// host: "test-mysql.example.com",
|
||||
// port: 3306,
|
||||
// databaseName: "testdb",
|
||||
// username: "testuser",
|
||||
// password: "testpass",
|
||||
// sslEnabled: true,
|
||||
// isActive: true,
|
||||
// },
|
||||
];
|
||||
|
||||
for (const conn of externalDbConnections) {
|
||||
// 비밀번호 암호화
|
||||
const encryptedPassword = encryption.encrypt(conn.password);
|
||||
|
||||
// 중복 체크 (이름 기준)
|
||||
const existingResult = await pool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[conn.name]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
console.log(
|
||||
`⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})`
|
||||
);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await pool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
conn.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await pool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
conn.name,
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
]
|
||||
);
|
||||
console.log(
|
||||
`✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})`
|
||||
);
|
||||
}
|
||||
|
||||
// 연결 테스트
|
||||
console.log(`🔍 연결 테스트 중: ${conn.name}...`);
|
||||
const testPool = new Pool({
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.databaseName,
|
||||
user: conn.username,
|
||||
password: conn.password,
|
||||
ssl: conn.sslEnabled,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await testPool.connect();
|
||||
await client.query("SELECT 1");
|
||||
client.release();
|
||||
console.log(`✅ 연결 테스트 성공: ${conn.name}`);
|
||||
} catch (testError: any) {
|
||||
console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message);
|
||||
} finally {
|
||||
await testPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 외부 DB 연결 정보 추가 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
addExternalDbConnection()
|
||||
.then(() => {
|
||||
console.log("✅ 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* dashboards 테이블 구조 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkDashboardStructure() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboards'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
||||
columns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
// 샘플 데이터 조회
|
||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
||||
const sample = await client.query(`
|
||||
SELECT * FROM dashboards LIMIT 1
|
||||
`);
|
||||
|
||||
if (sample.rows.length > 0) {
|
||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
||||
} else {
|
||||
console.log('❌ 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// dashboard_elements 테이블도 확인
|
||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
||||
|
||||
const elemColumns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboard_elements'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
||||
elemColumns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkDashboardStructure();
|
||||
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 데이터베이스 테이블 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkTables() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
||||
|
||||
// 테이블 목록 조회
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(`${index + 1}. ${row.table_name}`);
|
||||
});
|
||||
|
||||
// dashboard 관련 테이블 검색
|
||||
console.log('\n🔎 dashboard 관련 테이블:');
|
||||
const dashboardTables = result.rows.filter(row =>
|
||||
row.table_name.toLowerCase().includes('dashboard')
|
||||
);
|
||||
|
||||
if (dashboardTables.length === 0) {
|
||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
||||
} else {
|
||||
dashboardTables.forEach(row => {
|
||||
console.log(`✅ ${row.table_name}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* 비밀번호 암호화 유틸리티
|
||||
*/
|
||||
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
const password = process.argv[2] || "ph0909!!";
|
||||
|
||||
const encrypted = encryption.encrypt(password);
|
||||
console.log("\n원본 비밀번호:", password);
|
||||
console.log("암호화된 비밀번호:", encrypted);
|
||||
console.log("\n복호화 테스트:", encryption.decrypt(encrypted));
|
||||
console.log("✅ 암호화/복호화 성공\n");
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { query } from "../src/database/db";
|
||||
import { logger } from "../src/utils/logger";
|
||||
|
||||
/**
|
||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
||||
*
|
||||
* 목적:
|
||||
* - column_labels 테이블의 input_type 값을 읽어서
|
||||
* - 해당하는 기본 web_type 값으로 변환
|
||||
* - web_type이 null인 경우에만 업데이트
|
||||
*/
|
||||
|
||||
// input_type → 기본 web_type 매핑
|
||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
||||
text: "text", // 일반 텍스트
|
||||
number: "number", // 정수
|
||||
date: "date", // 날짜
|
||||
code: "code", // 코드 선택박스
|
||||
entity: "entity", // 엔티티 참조
|
||||
select: "select", // 선택박스
|
||||
checkbox: "checkbox", // 체크박스
|
||||
radio: "radio", // 라디오버튼
|
||||
direct: "text", // direct는 text로 매핑
|
||||
};
|
||||
|
||||
async function migrateInputTypeToWebType() {
|
||||
try {
|
||||
logger.info("=".repeat(60));
|
||||
logger.info("input_type → web_type 마이그레이션 시작");
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 1. 현재 상태 확인
|
||||
const stats = await query<{
|
||||
total: string;
|
||||
has_input_type: string;
|
||||
has_web_type: string;
|
||||
needs_migration: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const stat = stats[0];
|
||||
logger.info("\n📊 현재 상태:");
|
||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
||||
|
||||
if (parseInt(stat.needs_migration) === 0) {
|
||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. input_type별 분포 확인
|
||||
const distribution = await query<{
|
||||
input_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
input_type,
|
||||
COUNT(*) as count
|
||||
FROM column_labels
|
||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
||||
GROUP BY input_type
|
||||
ORDER BY input_type`
|
||||
);
|
||||
|
||||
logger.info("\n📋 input_type별 분포:");
|
||||
distribution.forEach((item) => {
|
||||
const webType =
|
||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
||||
});
|
||||
|
||||
// 3. 마이그레이션 실행
|
||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
||||
const result = await query(
|
||||
`UPDATE column_labels
|
||||
SET
|
||||
web_type = $1,
|
||||
updated_date = NOW()
|
||||
WHERE input_type = $2
|
||||
AND web_type IS NULL
|
||||
RETURNING id, table_name, column_name`,
|
||||
[webType, inputType]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
||||
);
|
||||
totalUpdated += result.length;
|
||||
|
||||
// 처음 5개만 출력
|
||||
result.slice(0, 5).forEach((row: any) => {
|
||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
||||
});
|
||||
if (result.length > 5) {
|
||||
logger.info(` ... 외 ${result.length - 5}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 결과 확인
|
||||
const afterStats = await query<{
|
||||
total: string;
|
||||
has_web_type: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const afterStat = afterStats[0];
|
||||
|
||||
logger.info("\n" + "=".repeat(60));
|
||||
logger.info("✅ 마이그레이션 완료!");
|
||||
logger.info("=".repeat(60));
|
||||
logger.info(`📊 최종 통계:`);
|
||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 5. 샘플 데이터 출력
|
||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
||||
const samples = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
web_type: string;
|
||||
detail_settings: string;
|
||||
}>(
|
||||
`SELECT
|
||||
column_name,
|
||||
input_type,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = 'check_report_mng'
|
||||
ORDER BY column_name
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
samples.forEach((sample) => {
|
||||
logger.info(
|
||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
||||
);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("❌ 마이그레이션 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
migrateInputTypeToWebType();
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* SQL 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-migration.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// DATABASE_URL에서 연결 정보 파싱
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
// 데이터베이스 연결 설정
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function runMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔄 마이그레이션 시작...\n');
|
||||
|
||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
||||
const sqlPath = '/tmp/migration.sql';
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('📄 SQL 파일 로드 완료');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
// SQL 실행
|
||||
await client.query(sql);
|
||||
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 마이그레이션 실패:');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error(error);
|
||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
runMigration();
|
||||
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||
* READ-ONLY: SELECT 쿼리만 실행
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function testDigitalTwinDb() {
|
||||
// 내부 DB 연결 (연결 정보 저장용)
|
||||
const internalPool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||
|
||||
// 디지털 트윈 외부 DB 연결 정보
|
||||
const digitalTwinConnection = {
|
||||
name: "디지털트윈_DO_DY",
|
||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||
host: "1.240.13.83",
|
||||
port: 4307,
|
||||
databaseName: "DO_DY",
|
||||
username: "root",
|
||||
password: "pohangms619!#",
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("📝 연결 정보:");
|
||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||
|
||||
// 1. 외부 DB 직접 연결 테스트
|
||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||
|
||||
const externalConnection = await mysql.createConnection({
|
||||
host: digitalTwinConnection.host,
|
||||
port: digitalTwinConnection.port,
|
||||
database: digitalTwinConnection.databaseName,
|
||||
user: digitalTwinConnection.username,
|
||||
password: digitalTwinConnection.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log("✅ 외부 DB 연결 성공!\n");
|
||||
|
||||
// 2. SELECT 쿼리 실행
|
||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SKUMKEY -- 제품번호
|
||||
, SKUDESC -- 자재명
|
||||
, SKUTHIC -- 두께
|
||||
, SKUWIDT -- 폭
|
||||
, SKULENG -- 길이
|
||||
, SKUWEIG -- 중량
|
||||
, STOTQTY -- 수량
|
||||
, SUOMKEY -- 단위
|
||||
FROM DO_DY.WSTKKY
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [rows] = await externalConnection.execute(query);
|
||||
|
||||
console.log("✅ 쿼리 실행 성공!\n");
|
||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||
console.log(`[${index + 1}]`);
|
||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||
});
|
||||
|
||||
// 전체 데이터 JSON 출력
|
||||
console.log("📄 전체 데이터 (JSON):");
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
await externalConnection.end();
|
||||
|
||||
// 3. 내부 DB에 연결 정보 저장
|
||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||
|
||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||
|
||||
// 중복 체크
|
||||
const existingResult = await internalPool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[digitalTwinConnection.name]
|
||||
);
|
||||
|
||||
let connectionId: number;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
connectionId = existingResult.rows[0].id;
|
||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await internalPool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
digitalTwinConnection.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await internalPool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
digitalTwinConnection.name,
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
]
|
||||
);
|
||||
connectionId = result.rows[0].id;
|
||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 테스트 완료!");
|
||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("\n❌ 오류 발생:", error.message);
|
||||
console.error("상세 정보:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await internalPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testDigitalTwinDb()
|
||||
.then(() => {
|
||||
console.log("\n🎉 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 마이그레이션 검증 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function verifyMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
||||
|
||||
// 전체 요소 수
|
||||
const total = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements
|
||||
`);
|
||||
|
||||
// 새로운 subtype별 개수
|
||||
const mapV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
||||
`);
|
||||
|
||||
const chart = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
||||
`);
|
||||
|
||||
const listV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
||||
`);
|
||||
|
||||
const metricV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
||||
`);
|
||||
|
||||
const alertV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
||||
`);
|
||||
|
||||
// 테스트 subtype 남아있는지 확인
|
||||
const remaining = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
||||
`);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📊 마이그레이션 결과 요약');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
||||
console.log(`chart: ${chart.rows[0].count}`);
|
||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
||||
console.log('');
|
||||
|
||||
if (parseInt(remaining.rows[0].count) > 0) {
|
||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
||||
} else {
|
||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
console.log('');
|
||||
console.log('다음 단계:');
|
||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
verifyMigration();
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import path from "path";
|
|||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
|
|
@ -31,10 +32,12 @@ import layoutRoutes from "./routes/layoutRoutes";
|
|||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
|
||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
|
|
@ -48,6 +51,38 @@ import externalCallRoutes from "./routes/externalCallRoutes";
|
|||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -75,21 +110,30 @@ app.use(compression());
|
|||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 (업로드된 파일들)
|
||||
app.use(
|
||||
"/uploads",
|
||||
express.static(path.join(process.cwd(), "uploads"), {
|
||||
setHeaders: (res, path) => {
|
||||
// 파일 서빙 시 CORS 헤더 설정
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
},
|
||||
})
|
||||
(req, res, next) => {
|
||||
// 모든 정적 파일 요청에 CORS 헤더 추가
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
|
|
@ -133,6 +177,10 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
|
|
@ -150,6 +198,7 @@ app.use("/api/multilang", multilangRoutes);
|
|||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -164,14 +213,17 @@ app.use("/api/layouts", layoutRoutes);
|
|||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
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/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
|
|
@ -181,6 +233,36 @@ app.use("/api/external-calls", externalCallRoutes);
|
|||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -208,13 +290,63 @@ app.listen(PORT, HOST, async () => {
|
|||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||
|
||||
// 데이터베이스 마이그레이션 실행
|
||||
try {
|
||||
const {
|
||||
runDashboardMigration,
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initialize();
|
||||
await BatchSchedulerService.initializeScheduler();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
|
||||
// 리스크/알림 자동 갱신 시작
|
||||
try {
|
||||
const { RiskAlertCacheService } = await import(
|
||||
"./services/riskAlertCacheService"
|
||||
);
|
||||
const cacheService = RiskAlertCacheService.getInstance();
|
||||
cacheService.startAutoRefresh();
|
||||
logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
|
||||
try {
|
||||
const cron = await import("node-cron");
|
||||
const { mailSentHistoryService } = await import(
|
||||
"./services/mailSentHistoryService"
|
||||
);
|
||||
|
||||
cron.schedule("0 2 * * *", async () => {
|
||||
try {
|
||||
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
||||
const deletedCount =
|
||||
await mailSentHistoryService.cleanupOldDeletedMails();
|
||||
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
||||
} catch (error) {
|
||||
logger.error("❌ 메일 자동 삭제 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
"https://v1.vexplor.com", // 운영 프론트엔드
|
||||
"https://api.vexplor.com", // 운영 백엔드
|
||||
];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
|
||||
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/uploads/mail-attachments'
|
||||
: path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
}
|
||||
|
||||
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||
function normalizeFileName(filename: string): string {
|
||||
if (!filename) return filename;
|
||||
|
||||
try {
|
||||
// NFC 정규화만 수행 (복잡한 디코딩 제거)
|
||||
return filename.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.error(`Failed to normalize filename: ${filename}`, error);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 저장 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 파일명 정규화 (한글-분석.txt 방식)
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
|
||||
console.log('File upload - Processing:', {
|
||||
original: file.originalname,
|
||||
originalHex: Buffer.from(file.originalname).toString('hex'),
|
||||
});
|
||||
|
||||
// UUID + 확장자로 유니크한 파일명 생성
|
||||
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${uniqueId}${ext}`;
|
||||
|
||||
console.log('Generated filename:', {
|
||||
original: file.originalname,
|
||||
generated: filename,
|
||||
});
|
||||
|
||||
cb(null, filename);
|
||||
} catch (error) {
|
||||
console.error('Filename processing error:', error);
|
||||
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
|
||||
cb(null, fallbackFilename);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 파일 필터 (허용할 파일 타입)
|
||||
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
|
||||
try {
|
||||
// NFD를 NFC로 정규화만 수행
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize filename in fileFilter:', error);
|
||||
}
|
||||
|
||||
// 위험한 파일 확장자 차단
|
||||
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
console.log(`❌ 차단된 파일 타입: ${ext}`);
|
||||
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 첨부파일 정보 추출 헬퍼
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
|
||||
return files.map((file) => ({
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
mimetype: file.mimetype,
|
||||
}));
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,295 @@
|
|||
import { Request, Response } from "express";
|
||||
import YardLayoutService from "../services/YardLayoutService";
|
||||
|
||||
export class YardLayoutController {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const layouts = await YardLayoutService.getAllLayouts();
|
||||
res.json({ success: true, data: layouts });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layouts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.getLayoutById(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const created_by = (req as any).user?.userId || "system";
|
||||
const layout = await YardLayoutService.createLayout({
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error creating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const layout = await YardLayoutService.updateLayout(parseInt(id), {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.deleteLayout(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "야드 레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placements = await YardLayoutService.getPlacementsByLayoutId(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: placements });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching placements:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소)
|
||||
async addMaterialPlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
// 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항
|
||||
// 빈 요소를 추가할 수 있어야 함
|
||||
|
||||
const placement = await YardLayoutService.addMaterialPlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error adding material placement:", error);
|
||||
|
||||
if (error.code === "23505") {
|
||||
// 유니크 제약 조건 위반
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 배치된 자재입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 배치 추가 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
const placement = await YardLayoutService.updatePlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 정보 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placement = await YardLayoutService.removePlacement(parseInt(id));
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "배치가 해제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 해제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { placements } = req.body;
|
||||
|
||||
if (!Array.isArray(placements) || placements.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "배치 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
|
||||
parseInt(id),
|
||||
placements
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: updatedPlacements });
|
||||
} catch (error: any) {
|
||||
console.error("Error batch updating placements:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "새 야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = await YardLayoutService.duplicateLayout(
|
||||
parseInt(id),
|
||||
name
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error duplicating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutController();
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -59,12 +59,56 @@ export class AuthController {
|
|||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const { AdminService } = await import("../services/adminService");
|
||||
const paramMap = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
userType: loginResult.userInfo.userType,
|
||||
userLang: "ko",
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
||||
// 2. MENU_URL이 있고 비어있지 않음
|
||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
||||
const firstMenu = menuList.find((menu: any) => {
|
||||
const level = menu.lev || menu.level;
|
||||
const url = menu.menu_url || menu.url;
|
||||
|
||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||
});
|
||||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
@ -97,6 +141,110 @@ export class AuthController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/switch-company
|
||||
* WACE 관리자 전용: 다른 회사로 전환
|
||||
*/
|
||||
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.body;
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 토큰이 필요합니다.",
|
||||
error: { code: "TOKEN_MISSING" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 확인
|
||||
const currentUser = JwtUtils.verifyToken(token);
|
||||
|
||||
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 전환할 회사 코드 검증
|
||||
if (!companyCode || companyCode.trim() === "") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "전환할 회사 코드가 필요합니다.",
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||
userId: currentUser.userId,
|
||||
originalCompanyCode: currentUser.companyCode,
|
||||
targetCompanyCode: companyCode,
|
||||
});
|
||||
|
||||
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||
if (companyCode !== "*") {
|
||||
const { query } = await import("../database/db");
|
||||
const companies = await query<any>(
|
||||
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (companies.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "존재하지 않는 회사 코드입니다.",
|
||||
error: { code: "COMPANY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||
const newPersonBean: PersonBean = {
|
||||
...currentUser,
|
||||
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||
};
|
||||
|
||||
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||
|
||||
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "회사 전환 완료",
|
||||
data: {
|
||||
token: newToken,
|
||||
companyCode: companyCode.trim(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회사 전환 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||
|
|
@ -182,13 +330,14 @@ export class AuthController {
|
|||
}
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
userName: dbUserInfo.userName || "",
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||
userType: dbUserInfo.userType || "USER",
|
||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
photo: dbUserInfo.photo,
|
||||
|
|
@ -340,4 +489,69 @@ export class AuthController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 공차중계 회원가입 API
|
||||
*/
|
||||
static async signup(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||
|
||||
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 입력값이 누락되었습니다.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회원가입 처리
|
||||
const signupResult = await AuthService.signupDriver({
|
||||
userId,
|
||||
password,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
vehicleType,
|
||||
});
|
||||
|
||||
if (signupResult.success) {
|
||||
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
});
|
||||
} else {
|
||||
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_FAILED",
|
||||
details: signupResult.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("공차중계 회원가입 API 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
|
|
@ -16,32 +21,36 @@ export interface AuthenticatedRequest extends Request {
|
|||
|
||||
export class BatchController {
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
* 배치 설정 목록 조회 (회사별)
|
||||
* GET /api/batch-configs
|
||||
*/
|
||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const filter: BatchConfigFilter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string
|
||||
is_active: isActive as string,
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
const result = await BatchService.getBatchConfigs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다."
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -50,10 +59,13 @@ export class BatchController {
|
|||
* 사용 가능한 커넥션 목록 조회
|
||||
* GET /api/batch-configs/connections
|
||||
*/
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
static async getAvailableConnections(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -63,7 +75,7 @@ export class BatchController {
|
|||
console.error("커넥션 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회에 실패했습니다."
|
||||
message: "커넥션 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -73,20 +85,26 @@ export class BatchController {
|
|||
* GET /api/batch-configs/connections/:type/tables
|
||||
* GET /api/batch-configs/connections/:type/:id/tables
|
||||
*/
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
static async getTablesFromConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (!type || (type !== 'internal' && type !== 'external')) {
|
||||
|
||||
if (!type || (type !== "internal" && type !== "external")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -96,7 +114,7 @@ export class BatchController {
|
|||
console.error("테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회에 실패했습니다."
|
||||
message: "테이블 목록 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -109,24 +127,28 @@ export class BatchController {
|
|||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
|
||||
if (!type || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요."
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
if (type !== "internal" && type !== "external") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -136,36 +158,36 @@ export class BatchController {
|
|||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회에 실패했습니다."
|
||||
message: "컬럼 정보 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회
|
||||
* 특정 배치 설정 조회 (회사별)
|
||||
* GET /api/batch-configs/:id
|
||||
*/
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const batchConfig = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다."
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -177,11 +199,17 @@ export class BatchController {
|
|||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -189,102 +217,123 @@ export class BatchController {
|
|||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings
|
||||
mappings,
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
|
||||
if (batchConfig.data && batchConfig.data.is_active === 'Y' && batchConfig.data.id) {
|
||||
await BatchSchedulerService.updateBatchSchedule(batchConfig.data.id, false);
|
||||
if (
|
||||
batchConfig.data &&
|
||||
batchConfig.data.is_active === "Y" &&
|
||||
batchConfig.data.id
|
||||
) {
|
||||
await BatchSchedulerService.updateBatchSchedule(
|
||||
batchConfig.data.id,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다."
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 수정
|
||||
* 배치 설정 수정 (회사별)
|
||||
* PUT /api/batch-configs/:id
|
||||
*/
|
||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive
|
||||
} as UpdateBatchConfigRequest);
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(
|
||||
Number(id),
|
||||
{
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive,
|
||||
} as UpdateBatchConfigRequest,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 수정되었습니다."
|
||||
message: "배치 설정이 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 수정에 실패했습니다."
|
||||
message: "배치 설정 수정에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 삭제 (논리 삭제)
|
||||
* 배치 설정 삭제 (논리 삭제, 회사별)
|
||||
* DELETE /api/batch-configs/:id
|
||||
*/
|
||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await BatchService.deleteBatchConfig(Number(id));
|
||||
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchService.deleteBatchConfig(
|
||||
Number(id),
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다."
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 삭제에 실패했습니다."
|
||||
message: "배치 설정 삭제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
|
||||
import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes";
|
||||
import {
|
||||
BatchExecutionLogFilter,
|
||||
CreateBatchExecutionLogRequest,
|
||||
UpdateBatchExecutionLogRequest,
|
||||
} from "../types/batchExecutionLogTypes";
|
||||
|
||||
export class BatchExecutionLogController {
|
||||
/**
|
||||
|
|
@ -18,7 +22,7 @@ export class BatchExecutionLogController {
|
|||
start_date,
|
||||
end_date,
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const filter: BatchExecutionLogFilter = {
|
||||
|
|
@ -27,11 +31,15 @@ export class BatchExecutionLogController {
|
|||
start_date: start_date ? new Date(start_date as string) : undefined,
|
||||
end_date: end_date ? new Date(end_date as string) : undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
};
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(filter);
|
||||
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -42,7 +50,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -53,9 +61,14 @@ export class BatchExecutionLogController {
|
|||
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
res.status(201).json(result);
|
||||
} else {
|
||||
|
|
@ -66,7 +79,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -78,9 +91,12 @@ export class BatchExecutionLogController {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const data: UpdateBatchExecutionLogRequest = req.body;
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data);
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(
|
||||
Number(id),
|
||||
data
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -91,7 +107,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -102,9 +118,11 @@ export class BatchExecutionLogController {
|
|||
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(Number(id));
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(
|
||||
Number(id)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -115,7 +133,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -126,9 +144,11 @@ export class BatchExecutionLogController {
|
|||
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchConfigId } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId));
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(
|
||||
Number(batchConfigId)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -139,7 +159,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -149,18 +169,14 @@ export class BatchExecutionLogController {
|
|||
*/
|
||||
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
const { batch_config_id, start_date, end_date } = req.query;
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionStats(
|
||||
batch_config_id ? Number(batch_config_id) : undefined,
|
||||
start_date ? new Date(start_date as string) : undefined,
|
||||
end_date ? new Date(end_date as string) : undefined
|
||||
);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -171,9 +187,8 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Response } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
|
||||
import {
|
||||
BatchManagementService,
|
||||
BatchConnectionInfo,
|
||||
BatchTableInfo,
|
||||
BatchColumnInfo,
|
||||
} from "../services/batchManagementService";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchManagementController {
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
* 사용 가능한 커넥션 목록 조회 (회사별)
|
||||
*/
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
static async getAvailableConnections(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchManagementService.getAvailableConnections();
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result =
|
||||
await BatchManagementService.getAvailableConnections(userCompanyCode);
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -26,28 +37,36 @@ export class BatchManagementController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 커넥션의 테이블 목록 조회
|
||||
* 특정 커넥션의 테이블 목록 조회 (회사별)
|
||||
*/
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
static async getTablesFromConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTablesFromConnection(
|
||||
type,
|
||||
connectionId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -58,28 +77,34 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
* 특정 테이블의 컬럼 정보 조회 (회사별)
|
||||
*/
|
||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTableColumns(
|
||||
type,
|
||||
connectionId,
|
||||
tableName,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -90,7 +115,7 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -101,12 +126,19 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -115,20 +147,20 @@ export class BatchManagementController {
|
|||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive: isActive !== undefined ? isActive : true
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -141,28 +173,28 @@ export class BatchManagementController {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
console.log("🔍 배치 설정 조회 요청:", id);
|
||||
|
||||
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다."
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.log("📋 조회된 배치 설정:", result.data);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -174,27 +206,27 @@ export class BatchManagementController {
|
|||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
|
||||
|
||||
const filter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string
|
||||
is_active: isActive as string,
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -206,20 +238,22 @@ export class BatchManagementController {
|
|||
static async executeBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
message: "올바른 배치 설정 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
// 배치 설정 조회
|
||||
const batchConfigResult = await BatchService.getBatchConfigById(Number(id));
|
||||
const batchConfigResult = await BatchService.getBatchConfigById(
|
||||
Number(id)
|
||||
);
|
||||
if (!batchConfigResult.success || !batchConfigResult.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -229,38 +263,53 @@ export class BatchManagementController {
|
|||
console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||
|
||||
let executionLog: any = null;
|
||||
|
||||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
execution_status: 'RUNNING',
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
success_records: 0,
|
||||
failed_records: 0
|
||||
failed_records: 0,
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import('../services/batchSchedulerService');
|
||||
const result = await BatchSchedulerService.executeBatchConfig(batchConfig);
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
);
|
||||
const result =
|
||||
await BatchSchedulerService.executeBatchConfig(batchConfig);
|
||||
|
||||
// result가 undefined인 경우 처리
|
||||
if (!result) {
|
||||
throw new Error('배치 실행 결과를 받을 수 없습니다.');
|
||||
throw new Error("배치 실행 결과를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
total_records: result.totalRecords,
|
||||
success_records: result.successRecords,
|
||||
failed_records: result.failedRecords
|
||||
failed_records: result.failedRecords,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
@ -270,45 +319,52 @@ export class BatchManagementController {
|
|||
totalRecords: result.totalRecords,
|
||||
successRecords: result.successRecords,
|
||||
failedRecords: result.failedRecords,
|
||||
executionTime: duration
|
||||
executionTime: duration,
|
||||
},
|
||||
message: "배치가 성공적으로 실행되었습니다."
|
||||
message: "배치가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
|
||||
} catch (batchError) {
|
||||
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError);
|
||||
|
||||
|
||||
// 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만
|
||||
try {
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== 'undefined') {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'FAILED',
|
||||
if (typeof executionLog !== "undefined" && executionLog) {
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
error_message:
|
||||
batchError instanceof Error
|
||||
? batchError.message
|
||||
: "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error('실행 로그 업데이트 실패:', logError);
|
||||
console.error("실행 로그 업데이트 실패:", logError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행에 실패했습니다.",
|
||||
error: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
error:
|
||||
batchError instanceof Error
|
||||
? batchError.message
|
||||
: "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -325,26 +381,29 @@ export class BatchManagementController {
|
|||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
message: "올바른 배치 설정 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(
|
||||
Number(id),
|
||||
updateData
|
||||
);
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 업데이트되었습니다."
|
||||
message: "배치 설정이 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 업데이트 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 업데이트에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -354,40 +413,88 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method = 'GET',
|
||||
const {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method = "GET",
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource
|
||||
paramSource,
|
||||
requestBody,
|
||||
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다."
|
||||
message: "API URL과 엔드포인트는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||
let finalApiKey = apiKey || "";
|
||||
if (authServiceName) {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 토큰 조회 가능
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 토큰만 조회
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName, companyCode];
|
||||
}
|
||||
|
||||
const tokenResult = await query<{ access_token: string }>(
|
||||
tokenQuery,
|
||||
tokenParams
|
||||
);
|
||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||
finalApiKey = tokenResult[0].access_token;
|
||||
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
||||
|
||||
console.log("REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
authServiceName: authServiceName || "직접 입력",
|
||||
dataArrayPath: dataArrayPath || "전체 응답",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
const { RestApiConnector } = await import('../database/RestApiConnector');
|
||||
|
||||
const { RestApiConnector } = await import("../database/RestApiConnector");
|
||||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
apiKey: finalApiKey,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
|
|
@ -396,7 +503,7 @@ export class BatchManagementController {
|
|||
// 파라미터가 있는 경우 엔드포인트 수정
|
||||
let finalEndpoint = endpoint;
|
||||
if (paramType && paramName && paramValue) {
|
||||
if (paramType === 'url') {
|
||||
if (paramType === "url") {
|
||||
// URL 파라미터: /api/users/{userId} → /api/users/123
|
||||
if (endpoint.includes(`{${paramName}}`)) {
|
||||
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
|
||||
|
|
@ -404,39 +511,101 @@ export class BatchManagementController {
|
|||
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
|
||||
finalEndpoint = `${endpoint}/${paramValue}`;
|
||||
}
|
||||
} else if (paramType === 'query') {
|
||||
} else if (paramType === "query") {
|
||||
// 쿼리 파라미터: /api/users?userId=123
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
if (requestBody && typeof requestBody === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
console.warn("Request Body JSON 파싱 실패:", e);
|
||||
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||
// 여기서는 경고 로그 남기고 진행
|
||||
}
|
||||
} else if (requestBody) {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
|
||||
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||
const result = await connector.executeRequest(
|
||||
finalEndpoint,
|
||||
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||
parsedBody
|
||||
);
|
||||
|
||||
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : 'undefined',
|
||||
firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data'
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||
});
|
||||
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
|
||||
// 데이터 배열 추출 헬퍼 함수
|
||||
const getValueByPath = (obj: any, path: string): any => {
|
||||
if (!path) return obj;
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// dataArrayPath가 있으면 해당 경로에서 배열 추출
|
||||
let extractedData: any[] = [];
|
||||
if (dataArrayPath) {
|
||||
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
|
||||
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
|
||||
const arrayData = getValueByPath(rawData, dataArrayPath);
|
||||
|
||||
if (Array.isArray(arrayData)) {
|
||||
extractedData = arrayData;
|
||||
console.log(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
|
||||
typeof arrayData
|
||||
);
|
||||
// 배열이 아니면 단일 객체로 처리
|
||||
if (arrayData) {
|
||||
extractedData = [arrayData];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// dataArrayPath가 없으면 기존 로직 사용
|
||||
extractedData = result.rows;
|
||||
}
|
||||
|
||||
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(
|
||||
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
|
||||
data
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
const fields = Object.keys(data[0]);
|
||||
console.log(`[previewRestApiData] 추출된 필드:`, fields);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: result.rowCount || data.length
|
||||
totalCount: extractedData.length,
|
||||
},
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`
|
||||
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
|
|
@ -444,9 +613,9 @@ export class BatchManagementController {
|
|||
data: {
|
||||
fields: [],
|
||||
samples: [],
|
||||
totalCount: 0
|
||||
totalCount: 0,
|
||||
},
|
||||
message: "API에서 데이터를 가져올 수 없습니다."
|
||||
message: "API에서 데이터를 가져올 수 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -454,7 +623,7 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "REST API 데이터 미리보기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -464,18 +633,28 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
} = req.body;
|
||||
|
||||
if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) {
|
||||
if (
|
||||
!batchName ||
|
||||
!batchType ||
|
||||
!cronSchedule ||
|
||||
!apiMappings ||
|
||||
apiMappings.length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다."
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -484,24 +663,40 @@ export class BatchManagementController {
|
|||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || '',
|
||||
description: description || "",
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: apiMappings
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
authServiceName: authServiceName || undefined,
|
||||
dataArrayPath: dataArrayPath || undefined,
|
||||
saveMode: saveMode || "INSERT",
|
||||
conflictKey: conflictKey || undefined,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`);
|
||||
await BatchSchedulerService.scheduleBatch(result.data);
|
||||
console.log(
|
||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||
);
|
||||
} catch (schedulerError) {
|
||||
console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError);
|
||||
// 스케줄러 등록 실패해도 배치 저장은 성공으로 처리
|
||||
|
|
@ -510,19 +705,66 @@ export class BatchManagementController {
|
|||
return res.json({
|
||||
success: true,
|
||||
message: "REST API 배치가 성공적으로 저장되었습니다.",
|
||||
data: result.data
|
||||
data: result.data,
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "배치 저장에 실패했습니다."
|
||||
message: result.message || "배치 저장에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 저장 중 오류가 발생했습니다."
|
||||
message: "배치 저장 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 토큰 서비스명 목록 조회
|
||||
*/
|
||||
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
let queryText: string;
|
||||
let queryParams: any[] = [];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 서비스 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
ORDER BY service_name`;
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 서비스만 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
AND company_code = $1
|
||||
ORDER BY service_name`;
|
||||
queryParams = [companyCode];
|
||||
}
|
||||
|
||||
const result = await query<{ service_name: string }>(
|
||||
queryText,
|
||||
queryParams
|
||||
);
|
||||
|
||||
const serviceNames = result.map((row) => row.service_name);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: serviceNames,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("인증 서비스 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BookingService } from "../services/bookingService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const bookingService = BookingService.getInstance();
|
||||
|
||||
/**
|
||||
* 모든 예약 조회
|
||||
*/
|
||||
export const getBookings = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { status, priority } = req.query;
|
||||
|
||||
const result = await bookingService.getAllBookings({
|
||||
status: status as string,
|
||||
priority: priority as string,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.bookings,
|
||||
newCount: result.newCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 목록 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 예약 수락
|
||||
*/
|
||||
export const acceptBooking = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const booking = await bookingService.acceptBooking(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: booking,
|
||||
message: "예약이 수락되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 수락 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 수락에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 예약 거절
|
||||
*/
|
||||
export const rejectBooking = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
const booking = await bookingService.rejectBooking(id, reason);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: booking,
|
||||
message: "예약이 거절되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 거절 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 거절에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
/**
|
||||
* 자동 입력 (Auto-Fill) 컨트롤러
|
||||
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 자동 입력 그룹 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 목록 조회
|
||||
*/
|
||||
export const getAutoFillGroups = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
g.*,
|
||||
COUNT(m.mapping_id) as mapping_count
|
||||
FROM cascading_auto_fill_group g
|
||||
LEFT JOIN cascading_auto_fill_mapping m
|
||||
ON g.group_code = m.group_code AND g.company_code = m.company_code
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("자동 입력 그룹 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||
*/
|
||||
export const getAutoFillGroupDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `
|
||||
SELECT * FROM cascading_auto_fill_group
|
||||
WHERE group_code = $1
|
||||
`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const groupResult = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!groupResult) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 매핑 정보 조회
|
||||
const mappingSql = `
|
||||
SELECT * FROM cascading_auto_fill_mapping
|
||||
WHERE group_code = $1 AND company_code = $2
|
||||
ORDER BY sort_order, mapping_id
|
||||
`;
|
||||
const mappingResult = await query(mappingSql, [
|
||||
groupCode,
|
||||
groupResult.company_code,
|
||||
]);
|
||||
|
||||
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...groupResult,
|
||||
mappings: mappingResult,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 그룹 코드 자동 생성 함수
|
||||
*/
|
||||
const generateAutoFillGroupCode = async (
|
||||
companyCode: string
|
||||
): Promise<string> => {
|
||||
const prefix = "AF";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 생성
|
||||
*/
|
||||
export const createAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
mappings = [],
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!groupName || !masterTable || !masterValueColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 코드 자동 생성
|
||||
const groupCode = await generateAutoFillGroupCode(companyCode);
|
||||
|
||||
// 그룹 생성
|
||||
const insertGroupSql = `
|
||||
INSERT INTO cascading_auto_fill_group (
|
||||
group_code, group_name, description,
|
||||
master_table, master_value_column, master_label_column,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const groupResult = await queryOne(insertGroupSql, [
|
||||
groupCode,
|
||||
groupName,
|
||||
description || null,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 매핑 생성
|
||||
if (mappings.length > 0) {
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const m = mappings[i];
|
||||
await query(
|
||||
`INSERT INTO cascading_auto_fill_mapping (
|
||||
group_code, company_code, source_column, target_field, target_label,
|
||||
is_editable, is_required, default_value, sort_order
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
groupCode,
|
||||
companyCode,
|
||||
m.sourceColumn,
|
||||
m.targetField,
|
||||
m.targetLabel || null,
|
||||
m.isEditable || "Y",
|
||||
m.isRequired || "N",
|
||||
m.defaultValue || null,
|
||||
m.sortOrder || i + 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 생성되었습니다.",
|
||||
data: groupResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 수정
|
||||
*/
|
||||
export const updateAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
isActive,
|
||||
mappings,
|
||||
} = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||
const checkParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 업데이트
|
||||
const updateSql = `
|
||||
UPDATE cascading_auto_fill_group SET
|
||||
group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
master_table = COALESCE($3, master_table),
|
||||
master_value_column = COALESCE($4, master_value_column),
|
||||
master_label_column = COALESCE($5, master_label_column),
|
||||
is_active = COALESCE($6, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE group_code = $7 AND company_code = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const updateResult = await queryOne(updateSql, [
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
isActive,
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
]);
|
||||
|
||||
// 매핑 업데이트 (전체 교체 방식)
|
||||
if (mappings !== undefined) {
|
||||
// 기존 매핑 삭제
|
||||
await query(
|
||||
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
|
||||
[groupCode, existing.company_code]
|
||||
);
|
||||
|
||||
// 새 매핑 추가
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const m = mappings[i];
|
||||
await query(
|
||||
`INSERT INTO cascading_auto_fill_mapping (
|
||||
group_code, company_code, source_column, target_field, target_label,
|
||||
is_editable, is_required, default_value, sort_order
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
m.sourceColumn,
|
||||
m.targetField,
|
||||
m.targetLabel || null,
|
||||
m.isEditable || "Y",
|
||||
m.isRequired || "N",
|
||||
m.defaultValue || null,
|
||||
m.sortOrder || i + 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 수정되었습니다.",
|
||||
data: updateResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 삭제
|
||||
*/
|
||||
export const deleteAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||
const deleteParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING group_code`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 자동 입력 데이터 조회 (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 마스터 옵션 목록 조회
|
||||
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||
*/
|
||||
export const getAutoFillMasterOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 마스터 테이블에서 옵션 조회
|
||||
const labelColumn = group.master_label_column || group.master_value_column;
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${group.master_value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${group.master_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
|
||||
if (companyCode !== "*") {
|
||||
// company_code 컬럼 존재 여부 확인
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[group.master_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${paramIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("자동 입력 마스터 옵션 조회", {
|
||||
groupCode,
|
||||
count: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 데이터 조회
|
||||
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||
*/
|
||||
export const getAutoFillData = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const { masterValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!masterValue) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "masterValue 파라미터가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 매핑 정보 조회
|
||||
const mappingSql = `
|
||||
SELECT * FROM cascading_auto_fill_mapping
|
||||
WHERE group_code = $1 AND company_code = $2
|
||||
ORDER BY sort_order
|
||||
`;
|
||||
const mappings = await query(mappingSql, [groupCode, group.company_code]);
|
||||
|
||||
if (mappings.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
mappings: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 마스터 테이블에서 데이터 조회
|
||||
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
|
||||
let dataSql = `
|
||||
SELECT ${sourceColumns}
|
||||
FROM ${group.master_table}
|
||||
WHERE ${group.master_value_column} = $1
|
||||
`;
|
||||
const dataParams: any[] = [masterValue];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[group.master_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
dataSql += ` AND company_code = $${paramIndex++}`;
|
||||
dataParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
const dataResult = await queryOne(dataSql, dataParams);
|
||||
|
||||
// 결과를 target_field 기준으로 변환
|
||||
const autoFillData: Record<string, any> = {};
|
||||
const mappingInfo: any[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const sourceValue = dataResult?.[mapping.source_column];
|
||||
const finalValue =
|
||||
sourceValue !== null && sourceValue !== undefined
|
||||
? sourceValue
|
||||
: mapping.default_value;
|
||||
|
||||
autoFillData[mapping.target_field] = finalValue;
|
||||
mappingInfo.push({
|
||||
targetField: mapping.target_field,
|
||||
targetLabel: mapping.target_label,
|
||||
value: finalValue,
|
||||
isEditable: mapping.is_editable === "Y",
|
||||
isRequired: mapping.is_required === "Y",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("자동 입력 데이터 조회", {
|
||||
groupCode,
|
||||
masterValue,
|
||||
fieldCount: mappingInfo.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: autoFillData,
|
||||
mappings: mappingInfo,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 데이터 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,562 @@
|
|||
/**
|
||||
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 조건부 연쇄 규칙 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 목록 조회
|
||||
*/
|
||||
export const getConditions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive, relationCode, relationType } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
// 관계 코드 필터
|
||||
if (relationCode) {
|
||||
sql += ` AND relation_code = $${paramIndex++}`;
|
||||
params.push(relationCode);
|
||||
}
|
||||
|
||||
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||
if (relationType) {
|
||||
sql += ` AND relation_type = $${paramIndex++}`;
|
||||
params.push(relationType);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 상세 조회
|
||||
*/
|
||||
export const getConditionDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const params: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, params);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 생성
|
||||
*/
|
||||
export const createCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
relationType = "RELATION",
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator = "EQ",
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority = 0,
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!relationCode ||
|
||||
!conditionName ||
|
||||
!conditionField ||
|
||||
!conditionValue ||
|
||||
!filterColumn ||
|
||||
!filterValues
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_condition (
|
||||
relation_type, relation_code, condition_name,
|
||||
condition_field, condition_operator, condition_value,
|
||||
filter_column, filter_values, priority,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
relationType,
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 생성", {
|
||||
conditionId: result?.condition_id,
|
||||
relationCode,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 수정
|
||||
*/
|
||||
export const updateCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 규칙 확인
|
||||
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const checkParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_condition SET
|
||||
condition_name = COALESCE($1, condition_name),
|
||||
condition_field = COALESCE($2, condition_field),
|
||||
condition_operator = COALESCE($3, condition_operator),
|
||||
condition_value = COALESCE($4, condition_value),
|
||||
filter_column = COALESCE($5, filter_column),
|
||||
filter_values = COALESCE($6, filter_values),
|
||||
priority = COALESCE($7, priority),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE condition_id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
Number(conditionId),
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 삭제
|
||||
*/
|
||||
export const deleteCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||
const deleteParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING condition_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 조건부 필터링 적용 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건에 따른 필터링된 옵션 조회
|
||||
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||
*/
|
||||
export const getFilteredOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { relationCode } = req.params;
|
||||
const { conditionFieldValue, parentValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 1. 기본 연쇄 관계 정보 조회
|
||||
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||
const relationParams: any[] = [relationCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
relationSql += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
|
||||
const relation = await queryOne(relationSql, relationParams);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||
let conditionSql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE relation_code = $1 AND is_active = 'Y'
|
||||
`;
|
||||
const conditionParams: any[] = [relationCode];
|
||||
let conditionParamIndex = 2;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||
conditionParams.push(companyCode);
|
||||
}
|
||||
|
||||
conditionSql += ` ORDER BY priority DESC`;
|
||||
|
||||
const conditions = await query(conditionSql, conditionParams);
|
||||
|
||||
// 3. 조건에 맞는 규칙 찾기
|
||||
let matchedCondition: any = null;
|
||||
|
||||
if (conditionFieldValue) {
|
||||
for (const cond of conditions) {
|
||||
const isMatch = evaluateCondition(
|
||||
conditionFieldValue as string,
|
||||
cond.condition_operator,
|
||||
cond.condition_value
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchedCondition = cond;
|
||||
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 옵션 조회 쿼리 생성
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label
|
||||
FROM ${relation.child_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 부모 값 필터 (기본 연쇄)
|
||||
if (parentValue) {
|
||||
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(parentValue);
|
||||
}
|
||||
|
||||
// 조건부 필터 적용
|
||||
if (matchedCondition) {
|
||||
const filterValues = matchedCondition.filter_values
|
||||
.split(",")
|
||||
.map((v: string) => v.trim());
|
||||
const placeholders = filterValues
|
||||
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
|
||||
.join(",");
|
||||
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||
optionsParams.push(...filterValues);
|
||||
optionsParamIndex += filterValues.length;
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.child_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (relation.child_order_column) {
|
||||
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||
}
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("조건부 필터링 옵션 조회", {
|
||||
relationCode,
|
||||
conditionFieldValue,
|
||||
parentValue,
|
||||
matchedCondition: matchedCondition?.condition_name,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
appliedCondition: matchedCondition
|
||||
? {
|
||||
conditionId: matchedCondition.condition_id,
|
||||
conditionName: matchedCondition.condition_name,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건 평가 함수
|
||||
*/
|
||||
function evaluateCondition(
|
||||
actualValue: string,
|
||||
operator: string,
|
||||
expectedValue: string
|
||||
): boolean {
|
||||
const actual = actualValue.toLowerCase().trim();
|
||||
const expected = expectedValue.toLowerCase().trim();
|
||||
|
||||
switch (operator.toUpperCase()) {
|
||||
case "EQ":
|
||||
case "=":
|
||||
case "EQUALS":
|
||||
return actual === expected;
|
||||
|
||||
case "NEQ":
|
||||
case "!=":
|
||||
case "<>":
|
||||
case "NOT_EQUALS":
|
||||
return actual !== expected;
|
||||
|
||||
case "CONTAINS":
|
||||
case "LIKE":
|
||||
return actual.includes(expected);
|
||||
|
||||
case "NOT_CONTAINS":
|
||||
case "NOT_LIKE":
|
||||
return !actual.includes(expected);
|
||||
|
||||
case "STARTS_WITH":
|
||||
return actual.startsWith(expected);
|
||||
|
||||
case "ENDS_WITH":
|
||||
return actual.endsWith(expected);
|
||||
|
||||
case "IN":
|
||||
const inValues = expected.split(",").map((v) => v.trim());
|
||||
return inValues.includes(actual);
|
||||
|
||||
case "NOT_IN":
|
||||
const notInValues = expected.split(",").map((v) => v.trim());
|
||||
return !notInValues.includes(actual);
|
||||
|
||||
case "GT":
|
||||
case ">":
|
||||
return parseFloat(actual) > parseFloat(expected);
|
||||
|
||||
case "GTE":
|
||||
case ">=":
|
||||
return parseFloat(actual) >= parseFloat(expected);
|
||||
|
||||
case "LT":
|
||||
case "<":
|
||||
return parseFloat(actual) < parseFloat(expected);
|
||||
|
||||
case "LTE":
|
||||
case "<=":
|
||||
return parseFloat(actual) <= parseFloat(expected);
|
||||
|
||||
case "IS_NULL":
|
||||
case "NULL":
|
||||
return actual === "" || actual === "null" || actual === "undefined";
|
||||
|
||||
case "IS_NOT_NULL":
|
||||
case "NOT_NULL":
|
||||
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,772 @@
|
|||
/**
|
||||
* 다단계 계층 (Hierarchy) 컨트롤러
|
||||
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 계층 그룹 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 계층 그룹 목록 조회
|
||||
*/
|
||||
export const getHierarchyGroups = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive, hierarchyType } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
|
||||
FROM cascading_hierarchy_group g
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
if (hierarchyType) {
|
||||
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
|
||||
params.push(hierarchyType);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY g.group_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 상세 조회 (레벨 포함)
|
||||
*/
|
||||
export const getHierarchyGroupDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 조회
|
||||
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 레벨 조회
|
||||
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||
const levelParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
levelSql += ` AND company_code = $2`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
levelSql += ` ORDER BY level_order`;
|
||||
|
||||
const levels = await query(levelSql, levelParams);
|
||||
|
||||
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...group,
|
||||
levels: levels,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 코드 자동 생성 함수
|
||||
*/
|
||||
const generateHierarchyGroupCode = async (
|
||||
companyCode: string
|
||||
): Promise<string> => {
|
||||
const prefix = "HG";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 생성
|
||||
*/
|
||||
export const createHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
hierarchyType = "MULTI_TABLE",
|
||||
maxLevels,
|
||||
isFixedLevels = "Y",
|
||||
// Self-reference 설정
|
||||
selfRefTable,
|
||||
selfRefIdColumn,
|
||||
selfRefParentColumn,
|
||||
selfRefValueColumn,
|
||||
selfRefLabelColumn,
|
||||
selfRefLevelColumn,
|
||||
selfRefOrderColumn,
|
||||
// BOM 설정
|
||||
bomTable,
|
||||
bomParentColumn,
|
||||
bomChildColumn,
|
||||
bomItemTable,
|
||||
bomItemIdColumn,
|
||||
bomItemLabelColumn,
|
||||
bomQtyColumn,
|
||||
bomLevelColumn,
|
||||
// 메시지
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
// 레벨 (MULTI_TABLE 타입인 경우)
|
||||
levels = [],
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!groupName || !hierarchyType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 코드 자동 생성
|
||||
const groupCode = await generateHierarchyGroupCode(companyCode);
|
||||
|
||||
// 그룹 생성
|
||||
const insertGroupSql = `
|
||||
INSERT INTO cascading_hierarchy_group (
|
||||
group_code, group_name, description, hierarchy_type,
|
||||
max_levels, is_fixed_levels,
|
||||
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
|
||||
bom_table, bom_parent_column, bom_child_column,
|
||||
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
|
||||
empty_message, no_options_message, loading_message,
|
||||
company_code, is_active, created_by, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const group = await queryOne(insertGroupSql, [
|
||||
groupCode,
|
||||
groupName,
|
||||
description || null,
|
||||
hierarchyType,
|
||||
maxLevels || null,
|
||||
isFixedLevels,
|
||||
selfRefTable || null,
|
||||
selfRefIdColumn || null,
|
||||
selfRefParentColumn || null,
|
||||
selfRefValueColumn || null,
|
||||
selfRefLabelColumn || null,
|
||||
selfRefLevelColumn || null,
|
||||
selfRefOrderColumn || null,
|
||||
bomTable || null,
|
||||
bomParentColumn || null,
|
||||
bomChildColumn || null,
|
||||
bomItemTable || null,
|
||||
bomItemIdColumn || null,
|
||||
bomItemLabelColumn || null,
|
||||
bomQtyColumn || null,
|
||||
bomLevelColumn || null,
|
||||
emptyMessage || "선택해주세요",
|
||||
noOptionsMessage || "옵션이 없습니다",
|
||||
loadingMessage || "로딩 중...",
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
// 레벨 생성 (MULTI_TABLE 타입인 경우)
|
||||
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
|
||||
for (const level of levels) {
|
||||
await query(
|
||||
`INSERT INTO cascading_hierarchy_level (
|
||||
group_code, company_code, level_order, level_name, level_code,
|
||||
table_name, value_column, label_column, parent_key_column,
|
||||
filter_column, filter_value, order_column, order_direction,
|
||||
placeholder, is_required, is_searchable, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
|
||||
[
|
||||
groupCode,
|
||||
companyCode,
|
||||
level.levelOrder,
|
||||
level.levelName,
|
||||
level.levelCode || null,
|
||||
level.tableName,
|
||||
level.valueColumn,
|
||||
level.labelColumn,
|
||||
level.parentKeyColumn || null,
|
||||
level.filterColumn || null,
|
||||
level.filterValue || null,
|
||||
level.orderColumn || null,
|
||||
level.orderDirection || "ASC",
|
||||
level.placeholder || `${level.levelName} 선택`,
|
||||
level.isRequired || "Y",
|
||||
level.isSearchable || "N",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "계층 그룹이 생성되었습니다.",
|
||||
data: group,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 수정
|
||||
*/
|
||||
export const updateHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
maxLevels,
|
||||
isFixedLevels,
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const checkParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_hierarchy_group SET
|
||||
group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
max_levels = COALESCE($3, max_levels),
|
||||
is_fixed_levels = COALESCE($4, is_fixed_levels),
|
||||
empty_message = COALESCE($5, empty_message),
|
||||
no_options_message = COALESCE($6, no_options_message),
|
||||
loading_message = COALESCE($7, loading_message),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_by = $9,
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE group_code = $10 AND company_code = $11
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
groupName,
|
||||
description,
|
||||
maxLevels,
|
||||
isFixedLevels,
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
isActive,
|
||||
userId,
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
]);
|
||||
|
||||
logger.info("계층 그룹 수정", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "계층 그룹이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 삭제
|
||||
*/
|
||||
export const deleteHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 레벨 먼저 삭제
|
||||
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||
const levelParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteLevelsSql += ` AND company_code = $2`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
await query(deleteLevelsSql, levelParams);
|
||||
|
||||
// 그룹 삭제
|
||||
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteGroupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteGroupSql += ` RETURNING group_code`;
|
||||
|
||||
const result = await queryOne(deleteGroupSql, groupParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("계층 그룹 삭제", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "계층 그룹이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 계층 레벨 관리
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 레벨 추가
|
||||
*/
|
||||
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
levelOrder,
|
||||
levelName,
|
||||
levelCode,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection = "ASC",
|
||||
placeholder,
|
||||
isRequired = "Y",
|
||||
isSearchable = "N",
|
||||
} = req.body;
|
||||
|
||||
// 그룹 존재 확인
|
||||
const groupCheck = await queryOne(
|
||||
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
|
||||
[groupCode, companyCode]
|
||||
);
|
||||
|
||||
if (!groupCheck) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_hierarchy_level (
|
||||
group_code, company_code, level_order, level_name, level_code,
|
||||
table_name, value_column, label_column, parent_key_column,
|
||||
filter_column, filter_value, order_column, order_direction,
|
||||
placeholder, is_required, is_searchable, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
groupCode,
|
||||
groupCheck.company_code,
|
||||
levelOrder,
|
||||
levelName,
|
||||
levelCode || null,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn || null,
|
||||
filterColumn || null,
|
||||
filterValue || null,
|
||||
orderColumn || null,
|
||||
orderDirection,
|
||||
placeholder || `${levelName} 선택`,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
]);
|
||||
|
||||
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "레벨이 추가되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 추가 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 추가에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 레벨 수정
|
||||
*/
|
||||
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { levelId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
levelName,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection,
|
||||
placeholder,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||
const checkParams: any[] = [Number(levelId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_hierarchy_level SET
|
||||
level_name = COALESCE($1, level_name),
|
||||
table_name = COALESCE($2, table_name),
|
||||
value_column = COALESCE($3, value_column),
|
||||
label_column = COALESCE($4, label_column),
|
||||
parent_key_column = COALESCE($5, parent_key_column),
|
||||
filter_column = COALESCE($6, filter_column),
|
||||
filter_value = COALESCE($7, filter_value),
|
||||
order_column = COALESCE($8, order_column),
|
||||
order_direction = COALESCE($9, order_direction),
|
||||
placeholder = COALESCE($10, placeholder),
|
||||
is_required = COALESCE($11, is_required),
|
||||
is_searchable = COALESCE($12, is_searchable),
|
||||
is_active = COALESCE($13, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE level_id = $14
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
levelName,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection,
|
||||
placeholder,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
isActive,
|
||||
Number(levelId),
|
||||
]);
|
||||
|
||||
logger.info("계층 레벨 수정", { levelId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "레벨이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 레벨 삭제
|
||||
*/
|
||||
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { levelId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||
const deleteParams: any[] = [Number(levelId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING level_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("계층 레벨 삭제", { levelId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "레벨이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 계층 옵션 조회 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 특정 레벨의 옵션 조회
|
||||
*/
|
||||
export const getLevelOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode, levelOrder } = req.params;
|
||||
const { parentValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 레벨 정보 조회
|
||||
let levelSql = `
|
||||
SELECT l.*, g.hierarchy_type
|
||||
FROM cascading_hierarchy_level l
|
||||
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
|
||||
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
|
||||
`;
|
||||
const levelParams: any[] = [groupCode, Number(levelOrder)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
levelSql += ` AND l.company_code = $3`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
const level = await queryOne(levelSql, levelParams);
|
||||
|
||||
if (!level) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 옵션 조회
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${level.value_column} as value,
|
||||
${level.label_column} as label
|
||||
FROM ${level.table_name}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 부모 값 필터 (레벨 2 이상)
|
||||
if (level.parent_key_column && parentValue) {
|
||||
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(parentValue);
|
||||
}
|
||||
|
||||
// 고정 필터
|
||||
if (level.filter_column && level.filter_value) {
|
||||
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(level.filter_value);
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[level.table_name]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (level.order_column) {
|
||||
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsSql += ` ORDER BY ${level.label_column}`;
|
||||
}
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("계층 레벨 옵션 조회", {
|
||||
groupCode,
|
||||
levelOrder,
|
||||
parentValue,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
levelInfo: {
|
||||
levelId: level.level_id,
|
||||
levelName: level.level_name,
|
||||
placeholder: level.placeholder,
|
||||
isRequired: level.is_required,
|
||||
isSearchable: level.is_searchable,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
/**
|
||||
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
||||
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 상호 배제 규칙 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 목록 조회
|
||||
*/
|
||||
export const getExclusions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM cascading_mutual_exclusion
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY exclusion_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("상호 배제 규칙 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 상세 조회
|
||||
*/
|
||||
export const getExclusionDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const params: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, params);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 배제 코드 자동 생성 함수
|
||||
*/
|
||||
const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||
const prefix = "EX";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 생성
|
||||
*/
|
||||
export const createExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
exclusionName,
|
||||
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType = "SAME_VALUE",
|
||||
errorMessage = "동일한 값을 선택할 수 없습니다",
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||
});
|
||||
}
|
||||
|
||||
// 배제 코드 자동 생성
|
||||
const exclusionCode = await generateExclusionCode(companyCode);
|
||||
|
||||
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
|
||||
const existingCheck = await queryOne(
|
||||
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
|
||||
[exclusionCode, companyCode]
|
||||
);
|
||||
|
||||
if (existingCheck) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 배제 코드입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_mutual_exclusion (
|
||||
exclusion_code, exclusion_name, field_names,
|
||||
source_table, value_column, label_column,
|
||||
exclusion_type, error_message,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
exclusionCode,
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn || null,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 생성되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 수정
|
||||
*/
|
||||
export const updateExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 규칙 확인
|
||||
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const checkParams: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_mutual_exclusion SET
|
||||
exclusion_name = COALESCE($1, exclusion_name),
|
||||
field_names = COALESCE($2, field_names),
|
||||
source_table = COALESCE($3, source_table),
|
||||
value_column = COALESCE($4, value_column),
|
||||
label_column = COALESCE($5, label_column),
|
||||
exclusion_type = COALESCE($6, exclusion_type),
|
||||
error_message = COALESCE($7, error_message),
|
||||
is_active = COALESCE($8, is_active)
|
||||
WHERE exclusion_id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
isActive,
|
||||
Number(exclusionId),
|
||||
]);
|
||||
|
||||
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 삭제
|
||||
*/
|
||||
export const deleteExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const deleteParams: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING exclusion_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 상호 배제 검증 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 상호 배제 검증
|
||||
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||
*/
|
||||
export const validateExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionCode } = req.params;
|
||||
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 배제 규칙 조회
|
||||
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||
const exclusionParams: any[] = [exclusionCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
exclusionSql += ` AND company_code = $2`;
|
||||
exclusionParams.push(companyCode);
|
||||
}
|
||||
|
||||
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||
|
||||
if (!exclusion) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 필드명 파싱
|
||||
const fields = exclusion.field_names
|
||||
.split(",")
|
||||
.map((f: string) => f.trim());
|
||||
|
||||
// 필드 값 수집
|
||||
const values: string[] = [];
|
||||
for (const field of fields) {
|
||||
if (fieldValues[field]) {
|
||||
values.push(fieldValues[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// 상호 배제 검증
|
||||
let isValid = true;
|
||||
let errorMessage = null;
|
||||
let conflictingFields: string[] = [];
|
||||
|
||||
if (exclusion.exclusion_type === "SAME_VALUE") {
|
||||
// 같은 값이 있는지 확인
|
||||
const uniqueValues = new Set(values);
|
||||
if (uniqueValues.size !== values.length) {
|
||||
isValid = false;
|
||||
errorMessage = exclusion.error_message;
|
||||
|
||||
// 충돌하는 필드 찾기
|
||||
const valueCounts: Record<string, string[]> = {};
|
||||
for (const field of fields) {
|
||||
const val = fieldValues[field];
|
||||
if (val) {
|
||||
if (!valueCounts[val]) {
|
||||
valueCounts[val] = [];
|
||||
}
|
||||
valueCounts[val].push(field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, fieldList] of Object.entries(valueCounts)) {
|
||||
if (fieldList.length > 1) {
|
||||
conflictingFields = fieldList;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("상호 배제 검증", {
|
||||
exclusionCode,
|
||||
isValid,
|
||||
fieldValues,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isValid,
|
||||
errorMessage: isValid ? null : errorMessage,
|
||||
conflictingFields,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 검증 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 검증에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드에 대한 배제 옵션 조회
|
||||
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||
*/
|
||||
export const getExcludedOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionCode } = req.params;
|
||||
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 배제 규칙 조회
|
||||
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||
const exclusionParams: any[] = [exclusionCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
exclusionSql += ` AND company_code = $2`;
|
||||
exclusionParams.push(companyCode);
|
||||
}
|
||||
|
||||
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||
|
||||
if (!exclusion) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 옵션 조회
|
||||
const labelColumn = exclusion.label_column || exclusion.value_column;
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${exclusion.value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${exclusion.source_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[exclusion.source_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 선택된 값 제외
|
||||
if (selectedValues) {
|
||||
const excludeValues = (selectedValues as string)
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v);
|
||||
if (excludeValues.length > 0) {
|
||||
const placeholders = excludeValues
|
||||
.map((_, i) => `$${optionsParamIndex + i}`)
|
||||
.join(",");
|
||||
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||
optionsParams.push(...excludeValues);
|
||||
}
|
||||
}
|
||||
|
||||
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("상호 배제 옵션 조회", {
|
||||
exclusionCode,
|
||||
currentField,
|
||||
excludedCount: (selectedValues as string)?.split(",").length || 0,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,798 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
/**
|
||||
* 연쇄 관계 목록 조회
|
||||
*/
|
||||
export const getCascadingRelations = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date,
|
||||
updated_by,
|
||||
updated_date
|
||||
FROM cascading_relation
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
|
||||
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 활성 상태 필터링
|
||||
if (isActive !== undefined) {
|
||||
query += ` AND is_active = $${paramIndex}`;
|
||||
params.push(isActive);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY relation_name ASC`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("연쇄 관계 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 상세 조회
|
||||
*/
|
||||
export const getCascadingRelationById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date,
|
||||
updated_by,
|
||||
updated_date
|
||||
FROM cascading_relation
|
||||
WHERE relation_id = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [id];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 코드로 조회
|
||||
*/
|
||||
export const getCascadingRelationByCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const params: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
query += ` LIMIT 1`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 생성
|
||||
*/
|
||||
export const createCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
const {
|
||||
relationCode,
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange,
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!relationCode ||
|
||||
!relationName ||
|
||||
!parentTable ||
|
||||
!parentValueColumn ||
|
||||
!childTable ||
|
||||
!childFilterColumn ||
|
||||
!childValueColumn ||
|
||||
!childLabelColumn
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 코드 체크
|
||||
const duplicateCheck = await pool.query(
|
||||
`SELECT relation_id FROM cascading_relation
|
||||
WHERE relation_code = $1 AND company_code = $2`,
|
||||
[relationCode, companyCode]
|
||||
);
|
||||
|
||||
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 관계 코드입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO cascading_relation (
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
relationCode,
|
||||
relationName,
|
||||
description || null,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn || null,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn || null,
|
||||
childOrderDirection || "ASC",
|
||||
emptyParentMessage || "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage || "선택 가능한 항목이 없습니다",
|
||||
loadingMessage || "로딩 중...",
|
||||
clearOnParentChange !== false ? "Y" : "N",
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("연쇄 관계 생성", {
|
||||
relationId: result.rows[0].relation_id,
|
||||
relationCode,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: "연쇄 관계가 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 생성 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 수정
|
||||
*/
|
||||
export const updateCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
const {
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 권한 체크
|
||||
const existingCheck = await pool.query(
|
||||
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingCheck.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "수정 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE cascading_relation SET
|
||||
relation_name = COALESCE($1, relation_name),
|
||||
description = COALESCE($2, description),
|
||||
parent_table = COALESCE($3, parent_table),
|
||||
parent_value_column = COALESCE($4, parent_value_column),
|
||||
parent_label_column = COALESCE($5, parent_label_column),
|
||||
child_table = COALESCE($6, child_table),
|
||||
child_filter_column = COALESCE($7, child_filter_column),
|
||||
child_value_column = COALESCE($8, child_value_column),
|
||||
child_label_column = COALESCE($9, child_label_column),
|
||||
child_order_column = COALESCE($10, child_order_column),
|
||||
child_order_direction = COALESCE($11, child_order_direction),
|
||||
empty_parent_message = COALESCE($12, empty_parent_message),
|
||||
no_options_message = COALESCE($13, no_options_message),
|
||||
loading_message = COALESCE($14, loading_message),
|
||||
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
|
||||
is_active = COALESCE($16, is_active),
|
||||
updated_by = $17,
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE relation_id = $18
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange !== undefined
|
||||
? clearOnParentChange
|
||||
? "Y"
|
||||
: "N"
|
||||
: null,
|
||||
isActive !== undefined ? (isActive ? "Y" : "N") : null,
|
||||
userId,
|
||||
id,
|
||||
]);
|
||||
|
||||
logger.info("연쇄 관계 수정", {
|
||||
relationId: id,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: "연쇄 관계가 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 수정 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 삭제
|
||||
*/
|
||||
export const deleteCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
// 권한 체크
|
||||
const existingCheck = await pool.query(
|
||||
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingCheck.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "삭제 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 소프트 삭제 (is_active = 'N')
|
||||
await pool.query(
|
||||
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
logger.info("연쇄 관계 삭제", {
|
||||
relationId: id,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "연쇄 관계가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||
* parent_table에서 전체 옵션을 조회합니다.
|
||||
*/
|
||||
export const getParentOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 관계 정보 조회
|
||||
let relationQuery = `
|
||||
SELECT
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const relationParams: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
relationQuery += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
relationQuery += ` LIMIT 1`;
|
||||
|
||||
const relationResult = await pool.query(relationQuery, relationParams);
|
||||
|
||||
if (relationResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 라벨 컬럼이 없으면 값 컬럼 사용
|
||||
const labelColumn =
|
||||
relation.parent_label_column || relation.parent_value_column;
|
||||
|
||||
// 부모 옵션 조회
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
${relation.parent_value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${relation.parent_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
const tableInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.parent_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [];
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
tableInfoResult.rowCount &&
|
||||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $1`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
|
||||
// status 컬럼이 있으면 활성 상태만 조회
|
||||
const statusInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'status'`,
|
||||
[relation.parent_table]
|
||||
);
|
||||
|
||||
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
|
||||
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
|
||||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("부모 옵션 조회", {
|
||||
relationCode: code,
|
||||
parentTable: relation.parent_table,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: optionsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("부모 옵션 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "부모 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계로 자식 옵션 조회
|
||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||
*
|
||||
* 다중 부모값 지원:
|
||||
* - parentValue: 단일 값 (예: "공정검사")
|
||||
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||
*/
|
||||
export const getCascadingOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const { parentValue, parentValues } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 다중 부모값 파싱
|
||||
let parentValueArray: string[] = [];
|
||||
|
||||
if (parentValues) {
|
||||
// parentValues가 있으면 우선 사용 (다중 선택)
|
||||
if (Array.isArray(parentValues)) {
|
||||
parentValueArray = parentValues.map(v => String(v));
|
||||
} else {
|
||||
// 콤마로 구분된 문자열
|
||||
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
} else if (parentValue) {
|
||||
// 기존 단일 값 호환
|
||||
parentValueArray = [String(parentValue)];
|
||||
}
|
||||
|
||||
if (parentValueArray.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "부모 값이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 관계 정보 조회
|
||||
let relationQuery = `
|
||||
SELECT
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const relationParams: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
relationQuery += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
relationQuery += ` LIMIT 1`;
|
||||
|
||||
const relationResult = await pool.query(relationQuery, relationParams);
|
||||
|
||||
if (relationResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
|
||||
let optionsQuery = `
|
||||
SELECT DISTINCT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label,
|
||||
${relation.child_filter_column} as parent_value
|
||||
FROM ${relation.child_table}
|
||||
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
const tableInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.child_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [...parentValueArray];
|
||||
let paramIndex = parentValueArray.length + 1;
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
tableInfoResult.rowCount &&
|
||||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||
optionsParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (relation.child_order_column) {
|
||||
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
|
||||
}
|
||||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||
relationCode: code,
|
||||
parentValues: parentValueArray,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: optionsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 옵션 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,456 @@
|
|||
import { Request, Response } from "express";
|
||||
import pool from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 병합 - 모든 관련 테이블에 적용
|
||||
* 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경
|
||||
*/
|
||||
export async function mergeCodeAllTables(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName, oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!columnName || !oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("코드 병합 시작", {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_all_tables($1, $2, $3, $4)",
|
||||
[columnName, oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리 (pool.query 반환 타입 처리)
|
||||
const affectedTables = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedTables.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("코드 병합 완료", {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedTables.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTables: affectedTables.map((row) => ({
|
||||
tableName: row.table_name,
|
||||
rowsUpdated: parseInt(row.rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼을 가진 테이블 목록 조회
|
||||
*/
|
||||
export async function getTablesWithColumn(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName } = req.params;
|
||||
|
||||
try {
|
||||
if (!columnName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "컬럼명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("컬럼을 가진 테이블 목록 조회", { columnName });
|
||||
|
||||
const query = `
|
||||
SELECT DISTINCT t.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
WHERE c.column_name = $1
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
ORDER BY t.table_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [columnName]);
|
||||
const rows = (result as any).rows || [];
|
||||
|
||||
logger.info(`컬럼을 가진 테이블 조회 완료: ${rows.length}개`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "테이블 목록 조회 성공",
|
||||
data: {
|
||||
columnName,
|
||||
tables: rows.map((row: any) => row.table_name),
|
||||
count: rows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 목록 조회 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "TABLE_LIST_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
*/
|
||||
export async function previewCodeMerge(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName, oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!columnName || !oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (columnName, oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode });
|
||||
|
||||
// 해당 컬럼을 가진 테이블 찾기
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT t.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
WHERE c.column_name = $1
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [columnName]);
|
||||
|
||||
// 각 테이블에서 영향받을 행 수 계산
|
||||
const preview = [];
|
||||
const tableRows = Array.isArray(tablesResult) ? tablesResult : ((tablesResult as any).rows || []);
|
||||
|
||||
for (const row of tableRows) {
|
||||
const tableName = row.table_name;
|
||||
|
||||
// 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가)
|
||||
// SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값
|
||||
const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`;
|
||||
|
||||
try {
|
||||
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
|
||||
const rows = (countResult as any).rows || [];
|
||||
const count = rows.length > 0 ? parseInt(rows[0].count) : 0;
|
||||
|
||||
if (count > 0) {
|
||||
preview.push({
|
||||
tableName,
|
||||
affectedRows: count,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`테이블 ${tableName} 조회 실패:`, error.message);
|
||||
// 테이블 접근 실패 시 건너뛰기
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0);
|
||||
|
||||
logger.info("코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
columnName,
|
||||
oldValue,
|
||||
preview,
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||
*/
|
||||
export async function mergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 시작", {
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||
[oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리
|
||||
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedData.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 완료", {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedData.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedData: affectedData.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
rowsUpdated: parseInt(row.out_rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 미리보기
|
||||
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||
*/
|
||||
export async function previewMergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||
[oldValue, companyCode]
|
||||
);
|
||||
|
||||
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = preview.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
oldValue,
|
||||
preview: preview.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
affectedRows: parseInt(row.out_affected_rows),
|
||||
})),
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -20,15 +20,25 @@ export class CommonCodeController {
|
|||
*/
|
||||
async getCategories(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { search, isActive, page = "1", size = "20" } = req.query;
|
||||
const { search, isActive, page = "1", size = "20", menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
const categories = await this.commonCodeService.getCategories({
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
});
|
||||
const categories = await this.commonCodeService.getCategories(
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
},
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -53,15 +63,26 @@ export class CommonCodeController {
|
|||
async getCodes(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { search, isActive, page, size } = req.query;
|
||||
const { search, isActive, page, size, menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
const result = await this.commonCodeService.getCodes(categoryCode, {
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
});
|
||||
const result = await this.commonCodeService.getCodes(
|
||||
categoryCode,
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
},
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||
const transformedData = result.data.map((code: any) => ({
|
||||
|
|
@ -73,7 +94,10 @@ export class CommonCodeController {
|
|||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active,
|
||||
useYn: code.is_active,
|
||||
|
||||
companyCode: code.company_code,
|
||||
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
depth: code.depth, // 계층구조: 깊이
|
||||
|
||||
// 기존 필드명도 유지 (하위 호환성)
|
||||
code_category: code.code_category,
|
||||
code_value: code.code_value,
|
||||
|
|
@ -81,6 +105,9 @@ export class CommonCodeController {
|
|||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
company_code: code.company_code,
|
||||
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
|
||||
created_date: code.created_date,
|
||||
created_by: code.created_by,
|
||||
updated_date: code.updated_date,
|
||||
|
|
@ -110,7 +137,9 @@ export class CommonCodeController {
|
|||
async createCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const categoryData: CreateCategoryData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM"; // 인증 미들웨어에서 설정된 사용자 ID
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const menuObjid = req.body.menuObjid;
|
||||
|
||||
// 입력값 검증
|
||||
if (!categoryData.categoryCode || !categoryData.categoryName) {
|
||||
|
|
@ -120,9 +149,18 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
|
||||
if (!menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "메뉴 OBJID는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const category = await this.commonCodeService.createCategory(
|
||||
categoryData,
|
||||
userId
|
||||
userId,
|
||||
companyCode,
|
||||
Number(menuObjid)
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -135,7 +173,7 @@ export class CommonCodeController {
|
|||
|
||||
// PostgreSQL 에러 처리
|
||||
if (
|
||||
((error as any)?.code === "23505") || // PostgreSQL unique_violation
|
||||
(error as any)?.code === "23505" || // PostgreSQL unique_violation
|
||||
(error instanceof Error && error.message.includes("Unique constraint"))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
|
|
@ -161,11 +199,13 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const categoryData: Partial<CreateCategoryData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const category = await this.commonCodeService.updateCategory(
|
||||
categoryCode,
|
||||
categoryData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -201,8 +241,9 @@ export class CommonCodeController {
|
|||
async deleteCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCategory(categoryCode);
|
||||
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -238,6 +279,8 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const codeData: CreateCodeData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const menuObjid = req.body.menuObjid;
|
||||
|
||||
// 입력값 검증
|
||||
if (!codeData.codeValue || !codeData.codeName) {
|
||||
|
|
@ -247,10 +290,17 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
|
||||
// 공통코드관리 메뉴 OBJID: 1757401858940
|
||||
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
|
||||
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
|
||||
|
||||
const code = await this.commonCodeService.createCode(
|
||||
categoryCode,
|
||||
codeData,
|
||||
userId
|
||||
userId,
|
||||
companyCode,
|
||||
effectiveMenuObjid
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -288,12 +338,14 @@ export class CommonCodeController {
|
|||
const { categoryCode, codeValue } = req.params;
|
||||
const codeData: Partial<CreateCodeData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const code = await this.commonCodeService.updateCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
codeData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -332,8 +384,13 @@ export class CommonCodeController {
|
|||
async deleteCode(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode, codeValue } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCode(categoryCode, codeValue);
|
||||
await this.commonCodeService.deleteCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -370,8 +427,12 @@ export class CommonCodeController {
|
|||
async getCodeOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const options = await this.commonCodeService.getCodeOptions(categoryCode);
|
||||
const options = await this.commonCodeService.getCodeOptions(
|
||||
categoryCode,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -424,12 +485,13 @@ export class CommonCodeController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 카테고리 중복 검사
|
||||
* 카테고리 중복 검사 (회사별)
|
||||
* GET /api/common-codes/categories/check-duplicate?field=categoryCode&value=USER_STATUS&excludeCode=OLD_CODE
|
||||
*/
|
||||
async checkCategoryDuplicate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { field, value, excludeCode } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 입력값 검증
|
||||
if (!field || !value) {
|
||||
|
|
@ -451,7 +513,8 @@ export class CommonCodeController {
|
|||
const result = await this.commonCodeService.checkCategoryDuplicate(
|
||||
field as "categoryCode" | "categoryName" | "categoryNameEng",
|
||||
value as string,
|
||||
excludeCode as string
|
||||
excludeCode as string,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -474,13 +537,14 @@ export class CommonCodeController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 코드 중복 검사
|
||||
* 코드 중복 검사 (회사별)
|
||||
* GET /api/common-codes/categories/:categoryCode/codes/check-duplicate?field=codeValue&value=ACTIVE&excludeCode=OLD_CODE
|
||||
*/
|
||||
async checkCodeDuplicate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { field, value, excludeCode } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 입력값 검증
|
||||
if (!field || !value) {
|
||||
|
|
@ -503,7 +567,8 @@ export class CommonCodeController {
|
|||
categoryCode,
|
||||
field as "codeValue" | "codeName" | "codeNameEng",
|
||||
value as string,
|
||||
excludeCode as string
|
||||
excludeCode as string,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -525,4 +590,129 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계층구조 코드 조회
|
||||
* GET /api/common-codes/categories/:categoryCode/hierarchy
|
||||
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
|
||||
*/
|
||||
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { parentCodeValue, depth, menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
|
||||
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
|
||||
? null
|
||||
: parentCodeValue as string;
|
||||
|
||||
const codes = await this.commonCodeService.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentValue,
|
||||
depth ? parseInt(depth as string) : undefined,
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
// 프론트엔드 형식으로 변환
|
||||
const transformedData = codes.map((code: any) => ({
|
||||
codeValue: code.code_value,
|
||||
codeName: code.code_name,
|
||||
codeNameEng: code.code_name_eng,
|
||||
description: code.description,
|
||||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active,
|
||||
parentCodeValue: code.parent_code_value,
|
||||
depth: code.depth,
|
||||
// 기존 필드도 유지
|
||||
code_category: code.code_category,
|
||||
code_value: code.code_value,
|
||||
code_name: code.code_name,
|
||||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
parent_code_value: code.parent_code_value,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: transformedData,
|
||||
message: `계층구조 코드 조회 성공 (${categoryCode})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 트리 조회
|
||||
* GET /api/common-codes/categories/:categoryCode/tree
|
||||
*/
|
||||
async getCodeTree(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
const result = await this.commonCodeService.getCodeTree(
|
||||
categoryCode,
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `코드 트리 조회 성공 (${categoryCode})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 트리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 코드 존재 여부 확인
|
||||
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
|
||||
*/
|
||||
async hasChildren(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode, codeValue } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const hasChildren = await this.commonCodeService.hasChildren(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { hasChildren },
|
||||
message: "자식 코드 확인 완료",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
|
||||
error
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자식 코드 확인 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
getDataflowDiagrams as getDataflowDiagramsService,
|
||||
getDataflowDiagramById as getDataflowDiagramByIdService,
|
||||
|
|
@ -12,15 +13,33 @@ import { logger } from "../utils/logger";
|
|||
/**
|
||||
* 관계도 목록 조회 (페이지네이션)
|
||||
*/
|
||||
export const getDataflowDiagrams = async (req: Request, res: Response) => {
|
||||
export const getDataflowDiagrams = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const size = parseInt(req.query.size as string) || 20;
|
||||
const searchTerm = req.query.searchTerm as string;
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
// 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
// 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info("관계도 목록 조회", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode,
|
||||
filterCompanyCode: companyCode,
|
||||
page,
|
||||
size,
|
||||
});
|
||||
|
||||
const result = await getDataflowDiagramsService(
|
||||
companyCode,
|
||||
|
|
@ -46,13 +65,21 @@ export const getDataflowDiagrams = async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 특정 관계도 조회
|
||||
*/
|
||||
export const getDataflowDiagramById = async (req: Request, res: Response) => {
|
||||
export const getDataflowDiagramById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -87,7 +114,10 @@ export const getDataflowDiagramById = async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 새로운 관계도 생성
|
||||
*/
|
||||
export const createDataflowDiagram = async (req: Request, res: Response) => {
|
||||
export const createDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const {
|
||||
diagram_name,
|
||||
|
|
@ -96,27 +126,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
} = req.body;
|
||||
|
||||
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
|
||||
// 회사 코드는 로그인한 사용자의 회사 코드 사용 (슈퍼 관리자는 요청 body에서 지정 가능)
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*" && req.body.company_code) {
|
||||
// 슈퍼 관리자가 특정 회사로 생성하는 경우
|
||||
companyCode = req.body.company_code;
|
||||
} else {
|
||||
// 일반 사용자/회사 관리자는 자신의 회사로 생성
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info(`새 관계도 생성 요청:`, {
|
||||
diagram_name,
|
||||
companyCode,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
});
|
||||
logger.info(`node_positions:`, node_positions);
|
||||
logger.info(`category:`, category);
|
||||
logger.info(`control:`, control);
|
||||
logger.info(`plan:`, plan);
|
||||
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
const companyCode =
|
||||
company_code ||
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
created_by ||
|
||||
updated_by ||
|
||||
(req.headers["x-user-id"] as string) ||
|
||||
"SYSTEM";
|
||||
|
||||
if (!diagram_name || !relationships) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -184,24 +218,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 관계도 수정
|
||||
*/
|
||||
export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
||||
export const updateDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const { updated_by } = req.body;
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
updated_by || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
|
||||
logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`);
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info(`관계도 수정 요청`, {
|
||||
diagramId,
|
||||
companyCode,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
});
|
||||
logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
logger.info(`node_positions:`, req.body.node_positions);
|
||||
logger.info(`요청 Body 키들:`, Object.keys(req.body));
|
||||
logger.info(`요청 Body 타입:`, typeof req.body);
|
||||
logger.info(`node_positions 타입:`, typeof req.body.node_positions);
|
||||
logger.info(`node_positions 값:`, req.body.node_positions);
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -265,13 +306,21 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 관계도 삭제
|
||||
*/
|
||||
export const deleteDataflowDiagram = async (req: Request, res: Response) => {
|
||||
export const deleteDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -306,21 +355,25 @@ export const deleteDataflowDiagram = async (req: Request, res: Response) => {
|
|||
/**
|
||||
* 관계도 복제
|
||||
*/
|
||||
export const copyDataflowDiagram = async (req: Request, res: Response) => {
|
||||
export const copyDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const {
|
||||
new_name,
|
||||
companyCode: bodyCompanyCode,
|
||||
userId: bodyUserId,
|
||||
} = req.body;
|
||||
const companyCode =
|
||||
bodyCompanyCode ||
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
const { new_name } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
|
||||
// 회사 코드는 로그인한 사용자의 회사 코드 사용
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*" && req.body.companyCode) {
|
||||
// 슈퍼 관리자가 특정 회사로 복제하는 경우
|
||||
companyCode = req.body.companyCode;
|
||||
} else {
|
||||
// 일반 사용자/회사 관리자는 자신의 회사로 복제
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
|
|||
|
|
@ -383,6 +383,79 @@ export class DDLController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/:tableName - 테이블 삭제 (최고 관리자 전용)
|
||||
*/
|
||||
static async dropTable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
|
||||
// 입력값 기본 검증
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("테이블 삭제 요청", {
|
||||
tableName,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// DDL 실행 서비스 호출
|
||||
const ddlService = new DDLExecutionService();
|
||||
const result = await ddlService.dropTable(
|
||||
tableName,
|
||||
userCompanyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
tableName,
|
||||
executedQuery: result.executedQuery,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 삭제 컨트롤러 오류:", {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
userId: req.user?.userId,
|
||||
tableName: req.params.tableName,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
details: "테이블 삭제 중 서버 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* 배송/화물 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import * as deliveryService from '../services/deliveryService';
|
||||
|
||||
/**
|
||||
* GET /api/delivery/status
|
||||
* 배송 현황 조회
|
||||
*/
|
||||
export async function getDeliveryStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await deliveryService.getDeliveryStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배송 현황 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '배송 현황 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/delivery/delayed
|
||||
* 지연 배송 목록 조회
|
||||
*/
|
||||
export async function getDelayedDeliveries(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deliveries = await deliveryService.getDelayedDeliveries();
|
||||
res.json({
|
||||
success: true,
|
||||
data: deliveries,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('지연 배송 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '지연 배송 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/delivery/issues
|
||||
* 고객 이슈 목록 조회
|
||||
*/
|
||||
export async function getCustomerIssues(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { status } = req.query;
|
||||
const issues = await deliveryService.getCustomerIssues(status as string);
|
||||
res.json({
|
||||
success: true,
|
||||
data: issues,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('고객 이슈 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '고객 이슈 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/delivery/:id/status
|
||||
* 배송 상태 업데이트
|
||||
*/
|
||||
export async function updateDeliveryStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, delayReason } = req.body;
|
||||
|
||||
await deliveryService.updateDeliveryStatus(id, status, delayReason);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '배송 상태가 업데이트되었습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배송 상태 업데이트 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '배송 상태 업데이트에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/delivery/issues/:id/status
|
||||
* 고객 이슈 상태 업데이트
|
||||
*/
|
||||
export async function updateIssueStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
await deliveryService.updateIssueStatus(id, status);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '이슈 상태가 업데이트되었습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이슈 상태 업데이트 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '이슈 상태 업데이트에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
import { Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별)
|
||||
*/
|
||||
export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("부서 목록 조회", { companyCode, userCompanyCode });
|
||||
|
||||
// 최고 관리자가 아니면 자신의 회사만 조회 가능
|
||||
if (userCompanyCode !== "*" && userCompanyCode !== companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "해당 회사의 부서를 조회할 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 부서 목록 조회 (부서원 수 포함)
|
||||
const departments = await query<any>(`
|
||||
SELECT
|
||||
d.dept_code,
|
||||
d.dept_name,
|
||||
d.company_code,
|
||||
d.parent_dept_code,
|
||||
COUNT(DISTINCT ud.user_id) as member_count
|
||||
FROM dept_info d
|
||||
LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code
|
||||
WHERE d.company_code = $1
|
||||
GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code
|
||||
ORDER BY d.dept_name
|
||||
`, [companyCode]);
|
||||
|
||||
// 응답 형식 변환
|
||||
const formattedDepartments = departments.map((dept) => ({
|
||||
dept_code: dept.dept_code,
|
||||
dept_name: dept.dept_name,
|
||||
company_code: dept.company_code,
|
||||
parent_dept_code: dept.parent_dept_code,
|
||||
memberCount: parseInt(dept.member_count || "0"),
|
||||
}));
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: formattedDepartments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 목록 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 상세 조회
|
||||
*/
|
||||
export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
const department = await queryOne<any>(`
|
||||
SELECT
|
||||
dept_code,
|
||||
dept_name,
|
||||
company_code,
|
||||
parent_dept_code
|
||||
FROM dept_info
|
||||
WHERE dept_code = $1
|
||||
`, [deptCode]);
|
||||
|
||||
if (!department) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: department,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 상세 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 생성
|
||||
*/
|
||||
export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const { dept_name, parent_dept_code } = req.body;
|
||||
|
||||
if (!dept_name || !dept_name.trim()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "부서명을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 회사 내 중복 부서명 확인
|
||||
const duplicate = await queryOne<any>(`
|
||||
SELECT dept_code, dept_name
|
||||
FROM dept_info
|
||||
WHERE company_code = $1 AND dept_name = $2
|
||||
`, [companyCode, dept_name.trim()]);
|
||||
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: `"${dept_name}" 부서가 이미 존재합니다.`,
|
||||
isDuplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 이름 조회
|
||||
const company = await queryOne<any>(`
|
||||
SELECT company_name FROM company_mng WHERE company_code = $1
|
||||
`, [companyCode]);
|
||||
|
||||
const companyName = company?.company_name || companyCode;
|
||||
|
||||
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
|
||||
const codeResult = await queryOne<any>(`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
|
||||
FROM dept_info
|
||||
WHERE dept_code ~ '^DEPT_[0-9]+$'
|
||||
`);
|
||||
|
||||
const nextNumber = codeResult?.next_number || 1;
|
||||
const deptCode = `DEPT_${nextNumber}`;
|
||||
|
||||
// 부서 생성
|
||||
const result = await query<any>(`
|
||||
INSERT INTO dept_info (
|
||||
dept_code,
|
||||
dept_name,
|
||||
company_code,
|
||||
company_name,
|
||||
parent_dept_code,
|
||||
status,
|
||||
regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
RETURNING *
|
||||
`, [
|
||||
deptCode,
|
||||
dept_name.trim(),
|
||||
companyCode,
|
||||
companyName,
|
||||
parent_dept_code || null,
|
||||
'active',
|
||||
]);
|
||||
|
||||
logger.info("부서 생성 성공", { deptCode, dept_name });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "부서가 생성되었습니다.",
|
||||
data: result[0],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 생성 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 수정
|
||||
*/
|
||||
export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
const { dept_name, parent_dept_code } = req.body;
|
||||
|
||||
if (!dept_name || !dept_name.trim()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "부서명을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await query<any>(`
|
||||
UPDATE dept_info
|
||||
SET
|
||||
dept_name = $1,
|
||||
parent_dept_code = $2
|
||||
WHERE dept_code = $3
|
||||
RETURNING *
|
||||
`, [dept_name.trim(), parent_dept_code || null, deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서 수정 성공", { deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "부서가 수정되었습니다.",
|
||||
data: result[0],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 수정 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제
|
||||
*/
|
||||
export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
// 하위 부서 확인
|
||||
const hasChildren = await queryOne<any>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM dept_info
|
||||
WHERE parent_dept_code = $1
|
||||
`, [deptCode]);
|
||||
|
||||
if (parseInt(hasChildren?.count || "0") > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 부서원 삭제 (부서 삭제 전에 먼저 삭제)
|
||||
const deletedMembers = await query<any>(`
|
||||
DELETE FROM user_dept
|
||||
WHERE dept_code = $1
|
||||
RETURNING user_id
|
||||
`, [deptCode]);
|
||||
|
||||
const memberCount = deletedMembers.length;
|
||||
|
||||
// 부서 삭제
|
||||
const result = await query<any>(`
|
||||
DELETE FROM dept_info
|
||||
WHERE dept_code = $1
|
||||
RETURNING dept_code, dept_name
|
||||
`, [deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서 삭제 성공", {
|
||||
deptCode,
|
||||
deptName: result[0].dept_name,
|
||||
deletedMemberCount: memberCount
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: memberCount > 0
|
||||
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
|
||||
: "부서가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 삭제 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
*/
|
||||
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
const members = await query<any>(`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.user_name,
|
||||
u.email,
|
||||
u.tel as phone,
|
||||
u.cell_phone,
|
||||
u.position_name,
|
||||
ud.dept_code,
|
||||
d.dept_name,
|
||||
ud.is_primary
|
||||
FROM user_dept ud
|
||||
JOIN user_info u ON ud.user_id = u.user_id
|
||||
JOIN dept_info d ON ud.dept_code = d.dept_code
|
||||
WHERE ud.dept_code = $1
|
||||
ORDER BY ud.is_primary DESC, u.user_name
|
||||
`, [deptCode]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: members,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 목록 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 검색 (부서원 추가용)
|
||||
*/
|
||||
export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const { search } = req.query;
|
||||
|
||||
if (!search || typeof search !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "검색어를 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 검색 (ID 또는 이름)
|
||||
const users = await query<any>(`
|
||||
SELECT
|
||||
user_id,
|
||||
user_name,
|
||||
email,
|
||||
position_name,
|
||||
company_code
|
||||
FROM user_info
|
||||
WHERE company_code = $1
|
||||
AND (
|
||||
user_id ILIKE $2 OR
|
||||
user_name ILIKE $2
|
||||
)
|
||||
ORDER BY user_name
|
||||
LIMIT 20
|
||||
`, [companyCode, `%${search}%`]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: users,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("사용자 검색 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 검색 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 추가
|
||||
*/
|
||||
export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
const { user_id } = req.body;
|
||||
|
||||
if (!user_id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자 ID를 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 존재 확인
|
||||
const user = await queryOne<any>(`
|
||||
SELECT user_id, user_name
|
||||
FROM user_info
|
||||
WHERE user_id = $1
|
||||
`, [user_id]);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 부서원인지 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT *
|
||||
FROM user_dept
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
`, [user_id, deptCode]);
|
||||
|
||||
if (existing) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 해당 부서의 부서원입니다.",
|
||||
isDuplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 주 부서가 있는지 확인
|
||||
const hasPrimary = await queryOne<any>(`
|
||||
SELECT *
|
||||
FROM user_dept
|
||||
WHERE user_id = $1 AND is_primary = true
|
||||
`, [user_id]);
|
||||
|
||||
// 부서원 추가
|
||||
await query<any>(`
|
||||
INSERT INTO user_dept (user_id, dept_code, is_primary, created_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
`, [user_id, deptCode, !hasPrimary]);
|
||||
|
||||
logger.info("부서원 추가 성공", { user_id, deptCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "부서원이 추가되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 추가 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 추가 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 제거
|
||||
*/
|
||||
export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode, userId } = req.params;
|
||||
|
||||
const result = await query<any>(`
|
||||
DELETE FROM user_dept
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
RETURNING *
|
||||
`, [userId, deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 부서원을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서원 제거 성공", { userId, deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "부서원이 제거되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 제거 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 제거 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주 부서 설정
|
||||
*/
|
||||
export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode, userId } = req.params;
|
||||
|
||||
// 다른 부서의 주 부서 해제
|
||||
await query<any>(`
|
||||
UPDATE user_dept
|
||||
SET is_primary = false
|
||||
WHERE user_id = $1
|
||||
`, [userId]);
|
||||
|
||||
// 해당 부서를 주 부서로 설정
|
||||
await query<any>(`
|
||||
UPDATE user_dept
|
||||
SET is_primary = true
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
`, [userId, deptCode]);
|
||||
|
||||
logger.info("주 부서 설정 성공", { userId, deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "주 부서가 설정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("주 부서 설정 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "주 부서 설정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import { Request, Response } from "express";
|
||||
import logger from "../utils/logger";
|
||||
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
|
||||
|
||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
|
||||
export async function getExternalDbConnector(connectionId: number) {
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
|
||||
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
||||
return {
|
||||
executeQuery: async (sql: string, params?: any[]) => {
|
||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||
return { rows: result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 동적 계층 구조 데이터 조회 (범용)
|
||||
export const getHierarchyData = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !hierarchyConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
const result: any = {
|
||||
warehouse: null,
|
||||
levels: [],
|
||||
materials: [],
|
||||
};
|
||||
|
||||
// 창고 데이터 조회
|
||||
if (config.warehouse) {
|
||||
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
|
||||
const warehouseResult = await connector.executeQuery(warehouseQuery);
|
||||
result.warehouse = warehouseResult.rows;
|
||||
}
|
||||
|
||||
// 각 레벨 데이터 조회
|
||||
if (config.levels && Array.isArray(config.levels)) {
|
||||
for (const level of config.levels) {
|
||||
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
||||
const levelResult = await connector.executeQuery(levelQuery);
|
||||
|
||||
result.levels.push({
|
||||
level: level.level,
|
||||
name: level.name,
|
||||
data: levelResult.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 데이터 조회 (개수만)
|
||||
if (config.material) {
|
||||
const materialQuery = `
|
||||
SELECT
|
||||
${config.material.locationKeyColumn} as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${config.material.tableName}
|
||||
GROUP BY ${config.material.locationKeyColumn}
|
||||
`;
|
||||
const materialResult = await connector.executeQuery(materialQuery);
|
||||
result.materials = materialResult.rows;
|
||||
}
|
||||
|
||||
logger.info("동적 계층 구조 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
warehouseCount: result.warehouse?.length || 0,
|
||||
levelCounts: result.levels.map((l: any) => ({
|
||||
level: l.level,
|
||||
count: l.data.length,
|
||||
})),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("동적 계층 구조 데이터 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레벨의 하위 데이터 조회
|
||||
export const getChildrenData = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!hierarchyConfig ||
|
||||
!parentLevel ||
|
||||
!parentKey
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
// 다음 레벨 찾기
|
||||
const nextLevel = config.levels?.find(
|
||||
(l: any) => l.level === parentLevel + 1
|
||||
);
|
||||
|
||||
if (!nextLevel) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "하위 레벨이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 하위 데이터 조회
|
||||
const query = `
|
||||
SELECT * FROM ${nextLevel.tableName}
|
||||
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("하위 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
parentLevel,
|
||||
parentKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("하위 데이터 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "하위 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getWarehouses = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("창고 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
count: result.rows.length,
|
||||
data: result.rows, // 실제 데이터 확인
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getAreas = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !warehouseKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE WAREKEY = '${warehouseKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("구역 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
warehouseKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구역 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "구역 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getLocations = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !areaKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE AREAKEY = '${areaKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("위치 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
areaKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "위치 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||
export const getMaterials = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
locaKey,
|
||||
tableName,
|
||||
keyColumn,
|
||||
locationKeyColumn,
|
||||
layerColumn,
|
||||
} = req.query;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!locaKey ||
|
||||
!tableName ||
|
||||
!locationKeyColumn
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
// 동적 쿼리 생성
|
||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||
${orderByClause}
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
logger.info(`자재 조회 쿼리: ${query}`);
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locaKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||
export const getMaterialCounts = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !locationKeys || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
LOCAKEY as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE LOCAKEY IN (${keysString})
|
||||
GROUP BY LOCAKEY
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 개수 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationCount: locationKeys.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 개수 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 개수 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
export const getLayouts = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { externalDbConnectionId, warehouseKey } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
l.*,
|
||||
u1.user_name as created_by_name,
|
||||
u2.user_name as updated_by_name,
|
||||
COUNT(o.id) as object_count
|
||||
FROM digital_twin_layout l
|
||||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||
if (companyCode && companyCode !== '*') {
|
||||
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
} else {
|
||||
query += ` WHERE 1=1`;
|
||||
}
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||
params.push(externalDbConnectionId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (warehouseKey) {
|
||||
query += ` AND l.warehouse_key = $${paramIndex}`;
|
||||
params.push(warehouseKey);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += `
|
||||
GROUP BY l.id, u1.user_name, u2.user_name
|
||||
ORDER BY l.updated_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("레이아웃 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 상세 조회 (객체 포함)
|
||||
export const getLayoutById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||
let layoutQuery: string;
|
||||
let layoutParams: any[];
|
||||
|
||||
if (companyCode && companyCode !== '*') {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
layoutParams = [id, companyCode];
|
||||
} else {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1
|
||||
`;
|
||||
layoutParams = [id];
|
||||
}
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 배치된 객체들 조회
|
||||
const objectsQuery = `
|
||||
SELECT *
|
||||
FROM digital_twin_objects
|
||||
WHERE layout_id = $1
|
||||
ORDER BY display_order, created_at
|
||||
`;
|
||||
|
||||
const objectsResult = await pool.query(objectsQuery, [id]);
|
||||
|
||||
logger.info("레이아웃 상세 조회", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objectsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
layout: layoutResult.rows[0],
|
||||
objects: objectsResult.rows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 상세 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 생성
|
||||
export const createLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 생성
|
||||
const layoutQuery = `
|
||||
INSERT INTO digital_twin_layout (
|
||||
company_code, external_db_connection_id, warehouse_key,
|
||||
layout_name, description, hierarchy_config, created_by, updated_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(layoutQuery, [
|
||||
companyCode,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const layoutId = layoutResult.rows[0].id;
|
||||
|
||||
// 객체들 저장
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
`;
|
||||
|
||||
for (const obj of objects) {
|
||||
await client.query(objectQuery, [
|
||||
layoutId,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
obj.parentId || null,
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 생성", {
|
||||
companyCode,
|
||||
layoutId,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 생성 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 수정
|
||||
export const updateLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 기본 정보 수정
|
||||
const updateLayoutQuery = `
|
||||
UPDATE digital_twin_layout
|
||||
SET layout_name = $1,
|
||||
description = $2,
|
||||
hierarchy_config = $3,
|
||||
external_db_connection_id = $4,
|
||||
warehouse_key = $5,
|
||||
updated_by = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7 AND company_code = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(updateLayoutQuery, [
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
externalDbConnectionId || null,
|
||||
warehouseKey || null,
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 객체 삭제
|
||||
await client.query(
|
||||
"DELETE FROM digital_twin_objects WHERE layout_id = $1",
|
||||
[id]
|
||||
);
|
||||
|
||||
// 새 객체 저장 (부모-자식 관계 처리)
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// 임시 ID (음수) → 실제 DB ID 매핑
|
||||
const idMapping: { [tempId: number]: number } = {};
|
||||
|
||||
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
|
||||
for (const obj of objects.filter((o) => !o.parentId)) {
|
||||
const result = await client.query(objectQuery, [
|
||||
id,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
null, // parent_id
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
|
||||
// 임시 ID와 실제 DB ID 매핑
|
||||
if (obj.id) {
|
||||
idMapping[obj.id] = result.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
|
||||
for (const obj of objects.filter((o) => o.parentId)) {
|
||||
const realParentId = idMapping[obj.parentId!] || null;
|
||||
|
||||
await client.query(objectQuery, [
|
||||
id,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
realParentId, // 실제 DB ID 사용
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 수정", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 삭제
|
||||
export const deleteLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `
|
||||
DELETE FROM digital_twin_layout
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("레이아웃 삭제", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
DigitalTwinTemplateService,
|
||||
DigitalTwinLayoutTemplate,
|
||||
} from "../services/DigitalTwinTemplateService";
|
||||
|
||||
export const listMappingTemplates = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const externalDbConnectionId = req.query.externalDbConnectionId
|
||||
? Number(req.query.externalDbConnectionId)
|
||||
: undefined;
|
||||
const layoutType =
|
||||
typeof req.query.layoutType === "string"
|
||||
? req.query.layoutType
|
||||
: undefined;
|
||||
|
||||
const result = await DigitalTwinTemplateService.listTemplates(
|
||||
companyCode,
|
||||
{
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data as DigitalTwinLayoutTemplate[],
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getMappingTemplateById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.getTemplateById(
|
||||
companyCode,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿을 찾을 수 없습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createMappingTemplate = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !externalDbConnectionId || !config) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.createTemplate(
|
||||
companyCode,
|
||||
userId,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
// 공차중계 운전자 컨트롤러
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
export class DriverController {
|
||||
/**
|
||||
* GET /api/driver/profile
|
||||
* 운전자 프로필 조회
|
||||
*/
|
||||
static async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const userResult = await query<any>(
|
||||
`SELECT
|
||||
user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userResult[0];
|
||||
|
||||
// 공차중계 사용자가 아닌 경우
|
||||
if (user.signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 차량 정보 조회
|
||||
const vehicleResult = await query<any>(
|
||||
`SELECT
|
||||
vehicle_number, vehicle_type, driver_name, driver_phone, status
|
||||
FROM vehicles
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
phoneNumber: user.cell_phone,
|
||||
licenseNumber: user.license_number,
|
||||
vehicleNumber: user.vehicle_number,
|
||||
vehicleType: vehicle?.vehicle_type || null,
|
||||
vehicleStatus: vehicle?.status || null,
|
||||
branchName: user.branch_name || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("운전자 프로필 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "프로필 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/driver/profile
|
||||
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
||||
*/
|
||||
static async updateProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body;
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const oldVehicleNumber = userCheck[0].vehicle_number;
|
||||
|
||||
// 차량번호 변경 시 중복 확인
|
||||
if (vehicleNumber && vehicleNumber !== oldVehicleNumber) {
|
||||
const duplicateCheck = await query<any>(
|
||||
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`,
|
||||
[vehicleNumber, userId]
|
||||
);
|
||||
|
||||
if (duplicateCheck.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량번호입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// user_info 업데이트
|
||||
await query(
|
||||
`UPDATE user_info SET
|
||||
user_name = COALESCE($1, user_name),
|
||||
cell_phone = COALESCE($2, cell_phone),
|
||||
license_number = COALESCE($3, license_number),
|
||||
vehicle_number = COALESCE($4, vehicle_number),
|
||||
branch_name = COALESCE($5, branch_name)
|
||||
WHERE user_id = $6`,
|
||||
[userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId]
|
||||
);
|
||||
|
||||
// vehicles 테이블 업데이트
|
||||
await query(
|
||||
`UPDATE vehicles SET
|
||||
vehicle_number = COALESCE($1, vehicle_number),
|
||||
vehicle_type = COALESCE($2, vehicle_type),
|
||||
driver_name = COALESCE($3, driver_name),
|
||||
driver_phone = COALESCE($4, driver_phone),
|
||||
branch_name = COALESCE($5, branch_name),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $6`,
|
||||
[vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId]
|
||||
);
|
||||
|
||||
logger.info(`운전자 프로필 수정 완료: ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "프로필이 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("운전자 프로필 수정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "프로필 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/driver/status
|
||||
* 차량 상태 변경 (대기/정비만 가능)
|
||||
*/
|
||||
static async updateStatus(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = req.body;
|
||||
|
||||
// 허용된 상태값만 (대기: off, 정비: maintenance)
|
||||
const allowedStatuses = ["off", "maintenance"];
|
||||
if (!status || !allowedStatuses.includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블 상태 업데이트
|
||||
const updateResult = await query(
|
||||
`UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`,
|
||||
[status, userId]
|
||||
);
|
||||
|
||||
logger.info(`차량 상태 변경: ${userId} -> ${status}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 상태 변경 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상태 변경 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/driver/vehicle
|
||||
* 차량 삭제 (user_id = NULL 처리, 기록 보존)
|
||||
*/
|
||||
static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존)
|
||||
await query(
|
||||
`UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// user_info에서 vehicle_number를 NULL로 변경
|
||||
await query(
|
||||
`UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
logger.info(`차량 삭제 완료 (기록 보존): ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "차량이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "차량 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/driver/vehicle
|
||||
* 새 차량 등록
|
||||
*/
|
||||
static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { vehicleNumber, vehicleType, branchName } = req.body;
|
||||
|
||||
if (!vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "차량번호는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 차량이 있는지 확인
|
||||
if (userCheck[0].vehicle_number) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 차량번호 중복 확인
|
||||
const duplicateCheck = await query<any>(
|
||||
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`,
|
||||
[vehicleNumber]
|
||||
);
|
||||
|
||||
if (duplicateCheck.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량번호입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userName = userCheck[0].user_name;
|
||||
const userPhone = userCheck[0].cell_phone;
|
||||
// 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용)
|
||||
const userCompanyCode = companyCode || userCheck[0].company_code;
|
||||
|
||||
// vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off')
|
||||
await query(
|
||||
`INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`,
|
||||
[vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode]
|
||||
);
|
||||
|
||||
// user_info에 vehicle_number 업데이트
|
||||
await query(
|
||||
`UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`,
|
||||
[vehicleNumber, userId]
|
||||
);
|
||||
|
||||
logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "차량이 등록되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 등록 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "차량 등록 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/driver/account
|
||||
* 회원 탈퇴 (차량 정보 포함 삭제)
|
||||
*/
|
||||
static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 탈퇴할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블에서 삭제
|
||||
await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]);
|
||||
|
||||
// user_info 테이블에서 삭제
|
||||
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||
|
||||
logger.info(`회원 탈퇴 완료: ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "회원 탈퇴가 완료되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("회원 탈퇴 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원 탈퇴 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,6 +12,14 @@ export const saveFormData = async (
|
|||
const { companyCode, userId } = req.user as any;
|
||||
const { screenId, tableName, data } = req.body;
|
||||
|
||||
// 🔍 디버깅: 사용자 정보 확인
|
||||
console.log("🔍 [saveFormData] 사용자 정보:", {
|
||||
userId,
|
||||
companyCode,
|
||||
reqUser: req.user,
|
||||
dataWriter: data.writer,
|
||||
});
|
||||
|
||||
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
||||
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -25,9 +33,12 @@ export const saveFormData = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer);
|
||||
|
||||
// company_code는 사용자가 명시적으로 입력한 경우에만 추가
|
||||
if (data.company_code !== undefined) {
|
||||
formDataWithMeta.company_code = data.company_code;
|
||||
|
|
@ -36,10 +47,18 @@ export const saveFormData = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// 클라이언트 IP 주소 추출
|
||||
const ipAddress =
|
||||
req.ip ||
|
||||
(req.headers["x-forwarded-for"] as string) ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown";
|
||||
|
||||
const result = await dynamicFormService.saveFormData(
|
||||
screenId,
|
||||
tableName,
|
||||
formDataWithMeta
|
||||
formDataWithMeta,
|
||||
ipAddress
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
@ -78,6 +97,7 @@ export const saveFormDataEnhanced = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
|
|
@ -126,6 +146,7 @@ export const updateFormData = async (
|
|||
const formDataWithMeta = {
|
||||
...data,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
|
|
@ -178,10 +199,11 @@ export const updateFormDataPartial = async (
|
|||
const newDataWithMeta = {
|
||||
...newData,
|
||||
updated_by: userId,
|
||||
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
parseInt(id),
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -208,8 +230,8 @@ export const deleteFormData = async (
|
|||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { tableName } = req.body;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, screenId } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -218,7 +240,16 @@ export const deleteFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원
|
||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||
|
||||
await dynamicFormService.deleteFormData(
|
||||
id,
|
||||
tableName,
|
||||
companyCode,
|
||||
userId,
|
||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -397,3 +428,207 @@ export const getTableColumns = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
export const updateFieldValue = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||
req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!tableName ||
|
||||
!keyField ||
|
||||
keyValue === undefined ||
|
||||
!updateField ||
|
||||
updateValue === undefined
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!validNamePattern.test(tableName) ||
|
||||
!validNamePattern.test(keyField) ||
|
||||
!validNamePattern.test(updateField)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트 쿼리 실행
|
||||
const result = await dynamicFormService.updateFieldValue(
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log("✅ [updateFieldValue] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "필드 값이 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [updateFieldValue] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 저장 (연속 위치 추적용)
|
||||
* POST /api/dynamic-form/location-history
|
||||
*/
|
||||
export const saveLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId: loginUserId } = req.user as any;
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt,
|
||||
vehicleId,
|
||||
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||
} = req.body;
|
||||
|
||||
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||
// 없으면 로그인한 사용자의 userId 사용
|
||||
const userId = requestUserId || loginUserId;
|
||||
|
||||
console.log("📍 [saveLocationHistory] 요청:", {
|
||||
userId,
|
||||
requestUserId,
|
||||
loginUserId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
tripId,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.saveLocationHistory({
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus: tripStatus || "active",
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt: recordedAt || new Date().toISOString(),
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
console.log("✅ [saveLocationHistory] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "위치 이력이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [saveLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 저장에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 조회 (경로 조회용)
|
||||
* GET /api/dynamic-form/location-history/:tripId
|
||||
*/
|
||||
export const getLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
const { userId, startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📍 [getLocationHistory] 요청:", {
|
||||
tripId,
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
});
|
||||
|
||||
const result = await dynamicFormService.getLocationHistory({
|
||||
companyCode,
|
||||
tripId,
|
||||
userId: userId as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 1000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
count: result.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ export class EntityJoinController {
|
|||
enableEntityJoin = true,
|
||||
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
|
@ -36,6 +39,7 @@ export class EntityJoinController {
|
|||
size,
|
||||
enableEntityJoin,
|
||||
search,
|
||||
autoFilter,
|
||||
});
|
||||
|
||||
// 검색 조건 처리
|
||||
|
|
@ -51,6 +55,43 @@ export class EntityJoinController {
|
|||
}
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 자동 필터 처리
|
||||
if (autoFilter) {
|
||||
try {
|
||||
const parsedAutoFilter =
|
||||
typeof autoFilter === "string" ? JSON.parse(autoFilter) : autoFilter;
|
||||
|
||||
if (parsedAutoFilter.enabled && (req as any).user) {
|
||||
const filterColumn = parsedAutoFilter.filterColumn || "company_code";
|
||||
const userField = parsedAutoFilter.userField || "companyCode";
|
||||
const userValue = ((req as any).user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
searchConditions[filterColumn] = finalCompanyCode;
|
||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||
filterColumn,
|
||||
finalCompanyCode,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("자동 필터 파싱 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 조인 컬럼 정보 처리
|
||||
let parsedAdditionalJoinColumns: any[] = [];
|
||||
if (additionalJoinColumns) {
|
||||
|
|
@ -84,6 +125,32 @@ export class EntityJoinController {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 데이터 필터 처리
|
||||
let parsedDataFilter: any = undefined;
|
||||
if (dataFilter) {
|
||||
try {
|
||||
parsedDataFilter =
|
||||
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
|
||||
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
|
||||
} catch (error) {
|
||||
logger.warn("데이터 필터 파싱 오류:", error);
|
||||
parsedDataFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let parsedExcludeFilter: any = undefined;
|
||||
if (excludeFilter) {
|
||||
try {
|
||||
parsedExcludeFilter =
|
||||
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
|
||||
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
|
||||
} catch (error) {
|
||||
logger.warn("제외 필터 파싱 오류:", error);
|
||||
parsedExcludeFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||
tableName,
|
||||
{
|
||||
|
|
@ -99,6 +166,8 @@ export class EntityJoinController {
|
|||
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -367,18 +436,16 @@ export class EntityJoinController {
|
|||
config.referenceTable
|
||||
);
|
||||
|
||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
const currentDisplayColumn =
|
||||
config.displayColumn || config.displayColumns[0];
|
||||
const availableColumns = columns.filter(
|
||||
(col) => col.columnName !== currentDisplayColumn
|
||||
);
|
||||
|
||||
|
||||
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||
return {
|
||||
joinConfig: config,
|
||||
tableName: config.referenceTable,
|
||||
currentDisplayColumn: currentDisplayColumn,
|
||||
availableColumns: availableColumns.map((col) => ({
|
||||
availableColumns: columns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
dataType: col.dataType,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
searchText = "",
|
||||
searchFields = "",
|
||||
filterCondition = "{}",
|
||||
page = "1",
|
||||
limit = "20",
|
||||
} = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const requestedFields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// 🆕 테이블의 실제 컬럼 목록 조회
|
||||
const pool = getPool();
|
||||
const columnsResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 🆕 존재하는 컬럼만 필터링
|
||||
const fields = requestedFields.filter((field) => {
|
||||
if (existingColumns.has(field)) {
|
||||
return true;
|
||||
} else {
|
||||
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const existingColumnsArray = Array.from(existingColumns);
|
||||
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||
if (existingColumns.has("company_code")) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText) {
|
||||
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||
let searchableFields = fields;
|
||||
if (searchableFields.length === 0) {
|
||||
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||
const defaultSearchColumns = [
|
||||
'name', 'code', 'description', 'title', 'label',
|
||||
'item_name', 'item_code', 'item_number',
|
||||
'equipment_name', 'equipment_code',
|
||||
'inspection_item', 'consumable_name', // 소모품명 추가
|
||||
'supplier_name', 'customer_name', 'product_name',
|
||||
];
|
||||
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
|
||||
}
|
||||
|
||||
if (searchableFields.length > 0) {
|
||||
const searchConditions = searchableFields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
searchableFields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 필터 조건 (존재하는 컬럼만)
|
||||
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
||||
// 특수 키 형식: column__operator (예: division__in, name__like)
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
// 특수 키 형식 파싱: column__operator
|
||||
let columnName = key;
|
||||
let operator = "=";
|
||||
|
||||
if (key.includes("__")) {
|
||||
const parts = key.split("__");
|
||||
columnName = parts[0];
|
||||
operator = parts[1] || "=";
|
||||
}
|
||||
|
||||
if (!existingColumns.has(columnName)) {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자별 WHERE 조건 생성
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${columnName}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
whereConditions.push(`"${columnName}" > $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<":
|
||||
whereConditions.push(`"${columnName}" < $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">=":
|
||||
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<=":
|
||||
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in":
|
||||
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
|
||||
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inValues.length > 0) {
|
||||
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" IN (${placeholders})`);
|
||||
params.push(...inValues);
|
||||
paramIndex += inValues.length;
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
// NOT IN 연산자
|
||||
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInValues.length > 0) {
|
||||
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
|
||||
params.push(...notInValues);
|
||||
paramIndex += notInValues.length;
|
||||
}
|
||||
break;
|
||||
case "like":
|
||||
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
default:
|
||||
// 알 수 없는 연산자는 등호로 처리
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이징
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const countResult = await pool.query(
|
||||
countQuery,
|
||||
params.slice(0, params.length - 2)
|
||||
);
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("엔티티 검색 성공", {
|
||||
tableName,
|
||||
searchText,
|
||||
companyCode,
|
||||
rowCount: dataResult.rowCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 검색 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import excelMappingService from "../services/excelMappingService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* POST /api/excel-mapping/find
|
||||
*/
|
||||
export async function findMappingByColumns(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 excelColumns(배열)가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.findMappingByColumns(
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (template) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "기존 매핑 템플릿을 찾았습니다.",
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: "일치하는 매핑 템플릿이 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* POST /api/excel-mapping/save
|
||||
*/
|
||||
export async function saveMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns, columnMappings } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!tableName || !excelColumns || !columnMappings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, excelColumns, columnMappings가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.saveMappingTemplate(
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "매핑 템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 저장 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 매핑 템플릿 목록 조회
|
||||
* GET /api/excel-mapping/list/:tableName
|
||||
*/
|
||||
export async function getMappingTemplates(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 목록 조회 요청", {
|
||||
tableName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const templates = await excelMappingService.getMappingTemplates(
|
||||
tableName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
* DELETE /api/excel-mapping/:id
|
||||
*/
|
||||
export async function deleteMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "id가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 삭제 요청", {
|
||||
id,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const deleted = await excelMappingService.deleteMappingTemplate(
|
||||
parseInt(id),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (deleted) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "매핑 템플릿이 삭제되었습니다.",
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +232,11 @@ export const uploadFiles = async (
|
|||
|
||||
// 자동 연결 로직 - target_objid 자동 생성
|
||||
let finalTargetObjid = targetObjid;
|
||||
if (autoLink === "true" && linkedTable && recordId) {
|
||||
|
||||
// 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시
|
||||
const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_'));
|
||||
|
||||
if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) {
|
||||
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
|
||||
if (isVirtualFileColumn === "true" && columnName) {
|
||||
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
|
||||
|
|
@ -337,6 +341,64 @@ export const uploadFiles = async (
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
||||
|
||||
// 🔍 디버깅: 레코드 모드 조건 확인
|
||||
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
||||
isRecordMode,
|
||||
linkedTable,
|
||||
recordId,
|
||||
columnName,
|
||||
finalTargetObjid,
|
||||
"req.body.isRecordMode": req.body.isRecordMode,
|
||||
"req.body.linkedTable": req.body.linkedTable,
|
||||
"req.body.recordId": req.body.recordId,
|
||||
"req.body.columnName": req.body.columnName,
|
||||
});
|
||||
|
||||
if (isRecordMode && linkedTable && recordId && columnName) {
|
||||
try {
|
||||
// 해당 레코드의 모든 첨부파일 조회
|
||||
const allFiles = await query<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||
ORDER BY regdate DESC`,
|
||||
[finalTargetObjid]
|
||||
);
|
||||
|
||||
// attachments JSONB 형태로 변환
|
||||
const attachmentsJson = allFiles.map((f: any) => ({
|
||||
objid: f.objid.toString(),
|
||||
realFileName: f.real_file_name,
|
||||
fileSize: Number(f.file_size),
|
||||
fileExt: f.file_ext,
|
||||
filePath: f.file_path,
|
||||
regdate: f.regdate?.toISOString(),
|
||||
}));
|
||||
|
||||
// 해당 테이블의 attachments 컬럼 업데이트
|
||||
// 🔒 멀티테넌시: company_code 필터 추가
|
||||
await query(
|
||||
`UPDATE ${linkedTable}
|
||||
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
||||
);
|
||||
|
||||
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
||||
tableName: linkedTable,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
fileCount: attachmentsJson.length,
|
||||
});
|
||||
} catch (updateError) {
|
||||
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
||||
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${files.length}개 파일 업로드 완료`,
|
||||
|
|
@ -363,12 +425,94 @@ export const deleteFile = async (
|
|||
const { objid } = req.params;
|
||||
const { writer = "system" } = req.body;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 삭제 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
||||
await query<any>(
|
||||
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
|
||||
["DELETED", parseInt(objid)]
|
||||
);
|
||||
|
||||
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||
const targetObjid = fileRecord.target_objid;
|
||||
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||
const parts = targetObjid.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const [tableName, recordId, columnName] = parts;
|
||||
|
||||
try {
|
||||
// 해당 레코드의 남은 첨부파일 조회
|
||||
const remainingFiles = await query<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||
ORDER BY regdate DESC`,
|
||||
[targetObjid]
|
||||
);
|
||||
|
||||
// attachments JSONB 형태로 변환
|
||||
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||
objid: f.objid.toString(),
|
||||
realFileName: f.real_file_name,
|
||||
fileSize: Number(f.file_size),
|
||||
fileExt: f.file_ext,
|
||||
filePath: f.file_path,
|
||||
regdate: f.regdate?.toISOString(),
|
||||
}));
|
||||
|
||||
// 해당 테이블의 attachments 컬럼 업데이트
|
||||
// 🔒 멀티테넌시: company_code 필터 추가
|
||||
await query(
|
||||
`UPDATE ${tableName}
|
||||
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||
);
|
||||
|
||||
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
remainingFiles: attachmentsJson.length,
|
||||
});
|
||||
} catch (updateError) {
|
||||
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "파일이 삭제되었습니다.",
|
||||
|
|
@ -510,6 +654,9 @@ export const getComponentFiles = async (
|
|||
const { screenId, componentId, tableName, recordId, columnName } =
|
||||
req.query;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
console.log("📂 [getComponentFiles] API 호출:", {
|
||||
screenId,
|
||||
componentId,
|
||||
|
|
@ -517,6 +664,7 @@ export const getComponentFiles = async (
|
|||
recordId,
|
||||
columnName,
|
||||
user: req.user?.userId,
|
||||
companyCode, // 🔒 멀티테넌시 로그
|
||||
});
|
||||
|
||||
if (!screenId || !componentId) {
|
||||
|
|
@ -534,32 +682,16 @@ export const getComponentFiles = async (
|
|||
templateTargetObjid,
|
||||
});
|
||||
|
||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||
const allFiles = await query<any>(
|
||||
`SELECT target_objid, real_file_name, regdate
|
||||
FROM attach_file_info
|
||||
WHERE status = $1
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 10`,
|
||||
["ACTIVE"]
|
||||
);
|
||||
console.log(
|
||||
"🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:",
|
||||
allFiles.map((f) => ({
|
||||
target_objid: f.target_objid,
|
||||
name: f.real_file_name,
|
||||
}))
|
||||
);
|
||||
|
||||
// 🔒 멀티테넌시: 회사별 필터링 추가
|
||||
const templateFiles = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = $2
|
||||
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
||||
ORDER BY regdate DESC`,
|
||||
[templateTargetObjid, "ACTIVE"]
|
||||
[templateTargetObjid, "ACTIVE", companyCode]
|
||||
);
|
||||
|
||||
console.log(
|
||||
"📁 [getComponentFiles] 템플릿 파일 결과:",
|
||||
"📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):",
|
||||
templateFiles.length
|
||||
);
|
||||
|
||||
|
|
@ -567,11 +699,12 @@ export const getComponentFiles = async (
|
|||
let dataFiles: any[] = [];
|
||||
if (tableName && recordId && columnName) {
|
||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
// 🔒 멀티테넌시: 회사별 필터링 추가
|
||||
dataFiles = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = $2
|
||||
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
||||
ORDER BY regdate DESC`,
|
||||
[dataTargetObjid, "ACTIVE"]
|
||||
[dataTargetObjid, "ACTIVE", companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -591,6 +724,7 @@ export const getComponentFiles = async (
|
|||
regdate: file.regdate?.toISOString(),
|
||||
status: file.status,
|
||||
isTemplate, // 템플릿 파일 여부 표시
|
||||
isRepresentative: file.is_representative || false, // 대표 파일 여부
|
||||
});
|
||||
|
||||
const formattedTemplateFiles = templateFiles.map((file) =>
|
||||
|
|
@ -643,6 +777,9 @@ export const previewFile = async (
|
|||
const { objid } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const fileRecord = await queryOne<any>(
|
||||
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||
[parseInt(objid)]
|
||||
|
|
@ -656,13 +793,28 @@ export const previewFile = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 접근 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
let fileCompanyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
if (fileCompanyCode === "company_*") {
|
||||
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
|
@ -674,7 +826,7 @@ export const previewFile = async (
|
|||
}
|
||||
|
||||
const companyUploadDir = getCompanyUploadDir(
|
||||
companyCode,
|
||||
fileCompanyCode,
|
||||
dateFolder || undefined
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
|
@ -724,8 +876,9 @@ export const previewFile = async (
|
|||
mimeType = "application/octet-stream";
|
||||
}
|
||||
|
||||
// CORS 헤더 설정 (더 포괄적으로)
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
|
||||
const origin = req.headers.origin || "http://localhost:9771";
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS"
|
||||
|
|
@ -762,6 +915,9 @@ export const downloadFile = async (
|
|||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
|
|
@ -775,13 +931,28 @@ export const downloadFile = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 다운로드 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
if (fileCompanyCode === "company_*") {
|
||||
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
|
@ -794,7 +965,7 @@ export const downloadFile = async (
|
|||
}
|
||||
|
||||
const companyUploadDir = getCompanyUploadDir(
|
||||
companyCode,
|
||||
fileCompanyCode,
|
||||
dateFolder || undefined
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
|
@ -1026,5 +1197,68 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 대표 파일 설정
|
||||
*/
|
||||
export const setRepresentativeFile = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 파일 존재 여부 및 권한 확인
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`,
|
||||
[parseInt(objid), "ACTIVE"]
|
||||
);
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 멀티테넌시: 회사 코드 확인
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 target_objid의 다른 파일들의 is_representative를 false로 설정
|
||||
await query<any>(
|
||||
`UPDATE attach_file_info
|
||||
SET is_representative = false
|
||||
WHERE target_objid = $1 AND objid != $2`,
|
||||
[fileRecord.target_objid, parseInt(objid)]
|
||||
);
|
||||
|
||||
// 선택한 파일을 대표 파일로 설정
|
||||
await query<any>(
|
||||
`UPDATE attach_file_info
|
||||
SET is_representative = true
|
||||
WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "대표 파일이 설정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("대표 파일 설정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대표 파일 설정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -0,0 +1,889 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* 플로우 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { FlowDefinitionService } from "../services/flowDefinitionService";
|
||||
import { FlowStepService } from "../services/flowStepService";
|
||||
import { FlowConnectionService } from "../services/flowConnectionService";
|
||||
import { FlowExecutionService } from "../services/flowExecutionService";
|
||||
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
||||
|
||||
export class FlowController {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
private flowStepService: FlowStepService;
|
||||
private flowConnectionService: FlowConnectionService;
|
||||
private flowExecutionService: FlowExecutionService;
|
||||
private flowDataMoveService: FlowDataMoveService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
this.flowStepService = new FlowStepService();
|
||||
this.flowConnectionService = new FlowConnectionService();
|
||||
this.flowExecutionService = new FlowExecutionService();
|
||||
this.flowDataMoveService = new FlowDataMoveService();
|
||||
}
|
||||
|
||||
// ==================== 플로우 정의 ====================
|
||||
|
||||
/**
|
||||
* 플로우 정의 생성
|
||||
*/
|
||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
console.log("🔍 createFlowDefinition called with:", {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
||||
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
|
||||
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
|
||||
const tableExists =
|
||||
await this.flowDefinitionService.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `Table '${tableName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.create(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||
},
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 목록 조회
|
||||
*/
|
||||
getFlowDefinitions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { tableName, isActive } = req.query;
|
||||
const user = (req as any).user;
|
||||
const userCompanyCode = user?.companyCode;
|
||||
|
||||
console.log("🎯 getFlowDefinitions called:", {
|
||||
userId: user?.userId,
|
||||
userCompanyCode: userCompanyCode,
|
||||
userType: user?.userType,
|
||||
tableName,
|
||||
isActive,
|
||||
});
|
||||
|
||||
const flows = await this.flowDefinitionService.findAll(
|
||||
tableName as string | undefined,
|
||||
isActive !== undefined ? isActive === "true" : undefined,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow definitions:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow definitions",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 상세 조회 (단계 및 연결 포함)
|
||||
*/
|
||||
getFlowDefinitionDetail = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
|
||||
const definition = await this.flowDefinitionService.findById(flowId);
|
||||
if (!definition) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = await this.flowStepService.findByFlowId(flowId);
|
||||
const connections = await this.flowConnectionService.findByFlowId(flowId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
definition,
|
||||
steps,
|
||||
connections,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow definition detail:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow definition detail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 수정
|
||||
*/
|
||||
updateFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
const { name, description, isActive } = req.body;
|
||||
|
||||
const flowDef = await this.flowDefinitionService.update(flowId, {
|
||||
name,
|
||||
description,
|
||||
isActive,
|
||||
});
|
||||
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 삭제
|
||||
*/
|
||||
deleteFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
|
||||
const success = await this.flowDefinitionService.delete(flowId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow definition deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 단계 ====================
|
||||
|
||||
/**
|
||||
* 플로우 단계 목록 조회
|
||||
*/
|
||||
getFlowSteps = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
|
||||
const steps = await this.flowStepService.findByFlowId(flowDefinitionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: steps,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow steps:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow steps",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 생성
|
||||
*/
|
||||
createFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
} = req.body;
|
||||
|
||||
if (!stepName || stepOrder === undefined) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "stepName and stepOrder are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const step = await this.flowStepService.create({
|
||||
flowDefinitionId,
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 수정
|
||||
*/
|
||||
updateFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
moveType,
|
||||
statusColumn,
|
||||
statusValue,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
integrationType,
|
||||
integrationConfig,
|
||||
displayConfig,
|
||||
} = req.body;
|
||||
|
||||
const step = await this.flowStepService.update(id, {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
moveType,
|
||||
statusColumn,
|
||||
statusValue,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
integrationType,
|
||||
integrationConfig,
|
||||
displayConfig,
|
||||
});
|
||||
|
||||
if (!step) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 삭제
|
||||
*/
|
||||
deleteFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
|
||||
const success = await this.flowStepService.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow step deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 연결 ====================
|
||||
|
||||
/**
|
||||
* 플로우 연결 목록 조회
|
||||
*/
|
||||
getFlowConnections = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
|
||||
const connections =
|
||||
await this.flowConnectionService.findByFlowId(flowDefinitionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connections,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow connections:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow connections",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 연결 생성
|
||||
*/
|
||||
createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
|
||||
|
||||
if (!flowDefinitionId || !fromStepId || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowDefinitionId, fromStepId, and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.flowConnectionService.create({
|
||||
flowDefinitionId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
label,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating connection:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create connection",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 연결 삭제
|
||||
*/
|
||||
deleteConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { connectionId } = req.params;
|
||||
const id = parseInt(connectionId);
|
||||
|
||||
const success = await this.flowConnectionService.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Connection not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Connection deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting connection:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete connection",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 실행 ====================
|
||||
|
||||
/**
|
||||
* 단계별 데이터 카운트 조회
|
||||
*/
|
||||
getStepDataCount = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
|
||||
const count = await this.flowExecutionService.getStepDataCount(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { count },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting step data count:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step data count",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 단계별 데이터 리스트 조회
|
||||
*/
|
||||
getStepDataList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
const { page = 1, pageSize = 20 } = req.query;
|
||||
|
||||
const data = await this.flowExecutionService.getStepDataList(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId),
|
||||
parseInt(page as string),
|
||||
parseInt(pageSize as string)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting step data list:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step data list",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 스텝의 컬럼 라벨 조회
|
||||
*/
|
||||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
||||
flowId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||
if (!step) {
|
||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.findById(
|
||||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
||||
stepTableName: step.tableName,
|
||||
flowTableName: flowDef.tableName,
|
||||
selectedTableName: tableName,
|
||||
});
|
||||
|
||||
if (!tableName) {
|
||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// column_labels 테이블에서 라벨 정보 조회
|
||||
const { query } = await import("../database/db");
|
||||
const labelRows = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1 AND column_label IS NOT NULL`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`✅ [FlowController] column_labels 조회 완료:`, {
|
||||
tableName,
|
||||
rowCount: labelRows.length,
|
||||
labels: labelRows.map((r) => ({
|
||||
col: r.column_name,
|
||||
label: r.column_label,
|
||||
})),
|
||||
});
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
labelRows.forEach((row) => {
|
||||
if (row.column_label) {
|
||||
labels[row.column_name] = row.column_label;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [FlowController] 컬럼 라벨 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step column labels",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우의 모든 단계별 카운트 조회
|
||||
*/
|
||||
getAllStepCounts = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const counts = await this.flowExecutionService.getAllStepCounts(
|
||||
parseInt(flowId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: counts,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting all step counts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get all step counts",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터를 다음 단계로 이동
|
||||
*/
|
||||
moveData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, recordId, toStepId, note } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (!flowId || !recordId || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, recordId, and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.flowDataMoveService.moveDataToStep(
|
||||
flowId,
|
||||
recordId,
|
||||
toStepId,
|
||||
userId,
|
||||
note
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data moved successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error moving data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to move data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 데이터를 동시에 이동
|
||||
*/
|
||||
moveBatchData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, fromStepId, toStepId, dataIds } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (
|
||||
!flowId ||
|
||||
!fromStepId ||
|
||||
!toStepId ||
|
||||
!dataIds ||
|
||||
!Array.isArray(dataIds)
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"flowId, fromStepId, toStepId, and dataIds (array) are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.flowDataMoveService.moveBatchData(
|
||||
flowId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
dataIds,
|
||||
userId
|
||||
);
|
||||
|
||||
const successCount = result.results.filter((r) => r.success).length;
|
||||
const failureCount = result.results.filter((r) => !r.success).length;
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `${successCount}건의 데이터를 성공적으로 이동했습니다`
|
||||
: `${successCount}건 성공, ${failureCount}건 실패`,
|
||||
data: {
|
||||
successCount,
|
||||
failureCount,
|
||||
total: dataIds.length,
|
||||
},
|
||||
results: result.results,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error moving batch data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to move batch data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터의 플로우 이력 조회
|
||||
*/
|
||||
getAuditLogs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, recordId } = req.params;
|
||||
|
||||
const logs = await this.flowDataMoveService.getAuditLogs(
|
||||
parseInt(flowId),
|
||||
recordId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting audit logs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get audit logs",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우의 모든 이력 조회
|
||||
*/
|
||||
getFlowAuditLogs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const { limit = 100 } = req.query;
|
||||
|
||||
const logs = await this.flowDataMoveService.getFlowAuditLogs(
|
||||
parseInt(flowId),
|
||||
parseInt(limit as string)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting flow audit logs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get flow audit logs",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스텝 데이터 업데이트 (인라인 편집)
|
||||
*/
|
||||
updateStepData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId, recordId } = req.params;
|
||||
const updateData = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
if (!flowId || !stepId || !recordId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, stepId, and recordId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updateData || Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "Update data is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.flowExecutionService.updateStepData(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId),
|
||||
recordId,
|
||||
updateData,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data updated successfully",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating step data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update step data",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import { Request, Response } from "express";
|
||||
import { FlowExternalDbConnectionService } from "../services/flowExternalDbConnectionService";
|
||||
import {
|
||||
CreateFlowExternalDbConnectionRequest,
|
||||
UpdateFlowExternalDbConnectionRequest,
|
||||
} from "../types/flow";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 플로우 전용 외부 DB 연결 컨트롤러
|
||||
*/
|
||||
export class FlowExternalDbConnectionController {
|
||||
private service: FlowExternalDbConnectionService;
|
||||
|
||||
constructor() {
|
||||
this.service = new FlowExternalDbConnectionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections
|
||||
* 모든 외부 DB 연결 목록 조회
|
||||
*/
|
||||
async getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const activeOnly = req.query.activeOnly === "true";
|
||||
const connections = await this.service.findAll(activeOnly);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 외부 DB 연결을 조회했습니다`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id
|
||||
* 특정 외부 DB 연결 조회
|
||||
*/
|
||||
async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.service.findById(id);
|
||||
|
||||
if (!connection) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flow/external-db-connections
|
||||
* 새 외부 DB 연결 생성
|
||||
*/
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const request: CreateFlowExternalDbConnectionRequest = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!request.name ||
|
||||
!request.dbType ||
|
||||
!request.host ||
|
||||
!request.port ||
|
||||
!request.databaseName ||
|
||||
!request.username ||
|
||||
!request.password
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const connection = await this.service.create(request, userId);
|
||||
|
||||
logger.info(
|
||||
`외부 DB 연결 생성: ${connection.name} (ID: ${connection.id})`
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: connection,
|
||||
message: "외부 DB 연결이 생성되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 생성 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/flow/external-db-connections/:id
|
||||
* 외부 DB 연결 수정
|
||||
*/
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const request: UpdateFlowExternalDbConnectionRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
const connection = await this.service.update(id, request, userId);
|
||||
|
||||
if (!connection) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`외부 DB 연결 수정: ${connection.name} (ID: ${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
message: "외부 DB 연결이 수정되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 수정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 수정 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/flow/external-db-connections/:id
|
||||
* 외부 DB 연결 삭제
|
||||
*/
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.service.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`외부 DB 연결 삭제: ID ${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "외부 DB 연결이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flow/external-db-connections/:id/test
|
||||
* 외부 DB 연결 테스트
|
||||
*/
|
||||
async testConnection(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.testConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`외부 DB 연결 테스트 성공: ID ${id}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`외부 DB 연결 테스트 실패: ID ${id} - ${result.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 테스트 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 테스트 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id/tables
|
||||
* 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
async getTables(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.getTables(id);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 테이블 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id/tables/:tableName/columns
|
||||
* 외부 DB 특정 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
async getTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const tableName = req.params.tableName;
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.getTableColumns(id, tableName);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 컬럼 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,12 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async getMailList(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('📬 메일 목록 조회 요청:', {
|
||||
// params: req.params,
|
||||
// path: req.path,
|
||||
// originalUrl: req.originalUrl
|
||||
// });
|
||||
|
||||
const { accountId } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
|
|
@ -43,6 +49,12 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async getMailDetail(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('🔍 메일 상세 조회 요청:', {
|
||||
// params: req.params,
|
||||
// path: req.path,
|
||||
// originalUrl: req.originalUrl
|
||||
// });
|
||||
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
|
|
@ -109,29 +121,39 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async downloadAttachment(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||
const { accountId, seqno, index } = req.params;
|
||||
// console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
||||
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
const indexNumber = parseInt(index, 10);
|
||||
|
||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||
// console.log('❌ 유효하지 않은 파라미터');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// console.log('📎 서비스 호출 시작...');
|
||||
const result = await this.mailReceiveService.downloadAttachment(
|
||||
accountId,
|
||||
seqnoNumber,
|
||||
indexNumber
|
||||
);
|
||||
// console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||
|
||||
if (!result) {
|
||||
// console.log('❌ 첨부파일을 찾을 수 없음');
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '첨부파일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||
// console.log(`📎 파일 경로: ${result.filePath}`);
|
||||
|
||||
// 파일 다운로드
|
||||
res.download(result.filePath, result.filename, (err) => {
|
||||
|
|
@ -173,5 +195,57 @@ export class MailReceiveBasicController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/today-count
|
||||
* 오늘 수신 메일 수 조회
|
||||
*/
|
||||
async getTodayReceivedCount(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.query;
|
||||
const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { count }
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mail/receive/:accountId/:seqno
|
||||
* IMAP 서버에서 메일 삭제
|
||||
*/
|
||||
async deleteMail(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
if (isNaN(seqnoNumber)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 메일 번호입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 삭제 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 삭제 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const mailReceiveBasicController = new MailReceiveBasicController();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue