From f36e8e93e26a65d977d2db941240fb773dfdc03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Tue, 23 Jun 2026 16:39:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20QR=20=EC=9E=90=EC=82=B0=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=EC=A0=90=EA=B2=80,=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9B=B9=EB=B7=B0=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(DB=20=EA=B8=B0=EB=B0=98=20=EB=A7=B5=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EC=A0=80=EC=9E=A5=20=EB=8B=A8=EC=9D=BC=ED=99=94=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- QR_system.md | 22 ++ index.html | 1 + map_config.json | 18 +- map_editor.html | 4 +- mobile.html | 299 ++++++++++++++++++++++ package-lock.json | 291 +++++++++++++++++++++- package.json | 2 + public/qrcode.min.js | 1 + scratch/db_migrate.cjs | 189 ++++++++++++++ scratch/test_audit.cjs | 231 +++++++++++++++++ server.js | 348 ++++++++++++++++++++++++-- src/components/Modal/HWModal.ts | 40 ++- src/components/Navigation.ts | 6 +- src/core/qr_print.ts | 250 +++++++++++++++++++ src/main.ts | 6 + src/mobile-main.ts | 180 ++++++++++++++ src/views/AuditApprovalView.ts | 427 ++++++++++++++++++++++++++++++++ src/views/List/ListFactory.ts | 2 +- src/views/MapEditor.ts | 77 +++++- vite.config.ts | 7 +- 21 files changed, 2357 insertions(+), 46 deletions(-) create mode 100644 QR_system.md create mode 100644 mobile.html create mode 100644 public/qrcode.min.js create mode 100644 scratch/db_migrate.cjs create mode 100644 scratch/test_audit.cjs create mode 100644 src/core/qr_print.ts create mode 100644 src/mobile-main.ts create mode 100644 src/views/AuditApprovalView.ts diff --git a/.env b/.env index bd7a495..5bb745c 100644 --- a/.env +++ b/.env @@ -3,4 +3,4 @@ DB_PORT=3306 DB_USER=itam_admin DB_PASS=itam1234 DB_NAME=itam -PORT=3000 \ No newline at end of file +PORT=3001 \ No newline at end of file diff --git a/QR_system.md b/QR_system.md new file mode 100644 index 0000000..6dc308b --- /dev/null +++ b/QR_system.md @@ -0,0 +1,22 @@ + 목적 + - 정기적인 실물자산 점검을 실시하여 시스템 내 자산정보의 정확성을 확보하고, 실제 자산의 위치 및 상태를 체계적으로 파악·관리할 수 있는 관리체계를 구축 + - QR 스캔 시스템을 통해 자산별 관리 이력 및 관리 책임자 정보를 즉시 확인할 수 있으며, 자산의 이동·변경 이력 추적과 안정적인 운영 관리를 추구 + + 구조 구성안 + A. 실제 위치 정보를 가진 마스터 테이블 구축 + - 현재 DB의 위치 정보는 건물 및 호수 정보(예: 기술개발센터 / 서버실)와 이미지 파일 내 픽셀 좌표 정보로 관리되고 있으며, 실제 서버가 설치된 랙(Rack) 및 물리적 위치 정보를 관리하는 항목은 존재하지 않음 + - 이미지 좌표 데이터와 실제 자산 위치 데이터를 연결하는 별도 마스터 테이블을 생성하여, 좌표 정보와 물리적 위치 정보 간 관계 정의 필요 + + B. 기존 테이블 개편 + - 픽셀 좌표 정보는 마스터 테이블에서 통합관리하고, 기존 테이블은 마스터 코드를상속받는 구조로 변경하여 유지 보수성을 확보 + + QR코드 정보 + - 자산 QR : 시스템에 등록된 자산 고유의 자산번호 + - 위치 QR : 물리적 위치 테이블에 저장된 마스터 코드 + + 현장실사 시나리오 + ① 담당자가 서버 렉 전면에 부착된 위치 QR을 스캔 + ② 위치 QR에 저장된 주소로 접속하여 세션에 현재 위치를 저장 + ③ 자산에 부착된 자산 QR을 스캔하여 주소에 접속하게 되면 정보를 매칭하여 API로 전송 + ④ 결합된 정보를 받아 기존 위치를 확인 혹은 업데이트 + ⑤ 시스템에서 관리자가 확인하여 승인하게 되면 시스템에도 업데이트 완료 \ No newline at end of file diff --git a/index.html b/index.html index 5a85b8d..dbeaba1 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" /> + diff --git a/map_config.json b/map_config.json index 064aab7..7f7e13c 100644 --- a/map_config.json +++ b/map_config.json @@ -112,7 +112,7 @@ "y": "32.01", "w": "40.87", "h": "6.24", - "asset_id": null + "asset_id": "9pvkqyi" } ], "img/location_photo/IDC/서관203.png": [ @@ -722,5 +722,21 @@ "h": "6.75", "asset_id": "server_1779761946023_64" } + ], + "img/location_photo/TDD_TEST_MAP.png": [ + { + "x": "30.50", + "y": "40.25", + "w": "10.00", + "h": "12.00", + "asset_id": null + }, + { + "x": "50.00", + "y": "60.00", + "w": "5.00", + "h": "5.00", + "asset_id": null + } ] } \ No newline at end of file diff --git a/map_editor.html b/map_editor.html index ee2c3f9..636121a 100644 --- a/map_editor.html +++ b/map_editor.html @@ -5,6 +5,7 @@ ITAM Map Coordinate Editor v3.0 + @@ -30,8 +31,9 @@
-
+
+
diff --git a/mobile.html b/mobile.html new file mode 100644 index 0000000..8050aa3 --- /dev/null +++ b/mobile.html @@ -0,0 +1,299 @@ + + + + + + ITAM 모바일 실사 점검 + + + + + + +
+

ITAM 모바일 실사

+
+
+ +
+
+
+
+
+ +
+ +
+ 현재 점검 위치 (Location) +
+ 위치 QR 코드를 먼저 스캔하세요. + +
+
+ +
+ + + + + +
+ 카메라가 안 되나요? 수동 코드로 입력 +
+ + +
+
+
+
+ + + + diff --git a/package-lock.json b/package-lock.json index af34cc8..b7b5dae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "iconv-lite": "^0.7.2", "lucide": "^0.364.0", "mysql2": "^3.22.1", + "qrcode": "^1.5.4", "xlsx": "^0.18.5" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "typescript": "^5.2.2", "vite": "^5.2.0" } @@ -774,11 +776,19 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -801,6 +811,28 @@ "node": ">=0.8" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -872,6 +904,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -885,6 +925,16 @@ "node": ">=0.8" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -894,6 +944,22 @@ "node": ">=0.8" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -980,6 +1046,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -998,6 +1072,11 @@ "node": ">= 0.8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -1030,6 +1109,11 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1187,6 +1271,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1247,6 +1343,14 @@ "is-property": "^1.0.2" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1371,6 +1475,14 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1383,6 +1495,17 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -1575,6 +1698,39 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1584,6 +1740,14 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -1601,6 +1765,14 @@ "dev": true, "license": "ISC" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -1643,6 +1815,22 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -1682,6 +1870,19 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1794,6 +1995,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1918,6 +2124,30 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1959,8 +2189,7 @@ "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -2040,6 +2269,11 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -2058,6 +2292,19 @@ "node": ">=0.8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2084,6 +2331,44 @@ "engines": { "node": ">=0.8" } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index f82fbff..a71cabe 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "db-init": "node db_init.js" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "typescript": "^5.2.2", "vite": "^5.2.0" }, @@ -21,6 +22,7 @@ "iconv-lite": "^0.7.2", "lucide": "^0.364.0", "mysql2": "^3.22.1", + "qrcode": "^1.5.4", "xlsx": "^0.18.5" } } diff --git a/public/qrcode.min.js b/public/qrcode.min.js new file mode 100644 index 0000000..fb20af6 --- /dev/null +++ b/public/qrcode.min.js @@ -0,0 +1 @@ +var QRCode=function(t){"use strict";function R(){return void 0!==a}var a,O=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706],Q=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||40>>=1;return e};function e(t,e){return t(e={exports:{}},e.exports),e.exports}var g=e(function(t,n){n.L={bit:1},n.M={bit:0},n.Q={bit:3},n.H={bit:2},n.isValid=function(t){return t&&void 0!==t.bit&&0<=t.bit&&t.bit<4},n.from=function(t,e){if(n.isValid(t))return t;try{var r=t;if("string"!=typeof r)throw new Error("Param is not a string");switch(r.toLowerCase()){case"l":case"low":return n.L;case"m":case"medium":return n.M;case"q":case"quartile":return n.Q;case"h":case"high":return n.H;default:throw new Error("Unknown EC Level: "+r)}return}catch(t){return e}}});function T(){this.buffer=[],this.length=0}g.L,g.M,g.Q,g.H,g.isValid,T.prototype={get:function(t){var e=Math.floor(t/8);return 1==(this.buffer[e]>>>7-t%8&1)},put:function(t,e){for(var r=0;r>>e-r-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),t&&(this.buffer[e]|=128>>>this.length%8),this.length++}};var W=T;function r(t){if(!t||t<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=t,this.data=new Uint8Array(t*t),this.reservedBit=new Uint8Array(t*t)}r.prototype.set=function(t,e,r,n){t=t*this.size+e;this.data[t]=r,n&&(this.reservedBit[t]=!0)},r.prototype.get=function(t,e){return this.data[t*this.size+e]},r.prototype.xor=function(t,e,r){this.data[t*this.size+e]^=r},r.prototype.isReserved=function(t,e){return this.reservedBit[t*this.size+e]};for(var G=r,V=e(function(t,i){var a=Q;i.getRowColCoords=function(t){if(1===t)return[];for(var e=Math.floor(t/7)+2,t=a(t),r=145===t?26:2*Math.ceil((t-13)/(2*e-2)),n=[t-7],o=1;o>6|192),e.push(63&a|128)):a<55296||57344<=a&&a<65536?(e.push(a>>12|224),e.push(a>>6&63|128),e.push(63&a|128)):65536<=a&&a<=1114111?(e.push(a>>18|240),e.push(a>>12&63|128),e.push(a>>6&63|128),e.push(63&a|128)):e.push(239,191,189)}return new Uint8Array(e).buffer}(t)),this.data=new Uint8Array(t)}N.getBitsLength=function(t){return 8*t},N.prototype.getLength=function(){return this.data.length},N.prototype.getBitsLength=function(){return N.getBitsLength(this.data.length)},N.prototype.write=function(t){for(var e=0,r=this.data.length;e>>8&255)+(255&r),13)}};var H=B,J=e(function(t){var d={single_source_shortest_paths:function(t,e,r){var n={},o={};o[e]=0;var a,i,u,s,h,f,c,g=d.PriorityQueue.make();for(g.push(e,0);!g.empty();)for(u in i=(a=g.pop()).value,s=a.cost,h=t[i]||{})h.hasOwnProperty(u)&&(f=s+h[u],c=o[u],(void 0===o[u]||f>i&1),i<6?t.set(i,8,n,!0):i<8?t.set(i+1,8,n,!0):t.set(o-15+i,8,n,!0),i<8?t.set(8,o-i-1,n,!0):i<9?t.set(8,15-i-1+1,n,!0):t.set(8,15-i-1,n,!0);t.set(o-8,8,1,!0)}function K(t,e,r,n){var o;if(Array.isArray(t))o=Z.fromArray(t);else{if("string"!=typeof t)throw new Error("Invalid data");var a=e;a||(i=Z.rawSplit(t),a=X.getBestVersionForData(i,r)),o=Z.fromString(t,a||40)}var i=X.getBestVersionForData(o,r);if(!i)throw new Error("The amount of data is too big to be stored in a QR Code");if(e){if(e>C&1),!0),N.set(_,B,z,!0)}for(var P=i,K=t,R=P.size,T=-1,L=R-1,b=7,U=0,x=R-1;0>>b&1)),P.set(L,x-F,k),-1==--b&&(U++,b=7));if((L+=T)<0||R<=L){L-=T,T=-T;break}}return isNaN(n)&&(n=q.getBestMask(i,nt.bind(null,i,r))),q.applyMask(n,i),nt(i,r,n),{modules:i,version:e,errorCorrectionLevel:r,maskPattern:n,segments:o}}Z.fromArray,Z.fromString,Z.rawSplit;function ot(t,e){if(void 0===t||""===t)throw new Error("No input text");var r,n,o=g.M;if(void 0!==e&&(o=g.from(e.errorCorrectionLevel,g.M),r=X.from(e.version),n=q.from(e.maskPattern),e.toSJISFunc)){if("function"!=typeof(e=e.toSJISFunc))throw new Error('"toSJISFunc" is not a valid function.');a=e}return K(t,r,o,n)}var C=e(function(t,d){function o(t){if("string"!=typeof(t="number"==typeof t?t.toString():t))throw new Error("Color should be defined as hex string");var e=t.slice().replace("#","").split("");if(e.length<3||5===e.length||8>24&255,g:t>>16&255,b:t>>8&255,a:255&t,hex:"#"+e.slice(0,6).join("")}}d.getOptions=function(t){(t=t||{}).color||(t.color={});var e=void 0===t.margin||null===t.margin||t.margin<0?4:t.margin,r=t.width&&21<=t.width?t.width:void 0,n=t.scale||4;return{width:r,scale:r?4:n,margin:e,color:{dark:o(t.color.dark||"#000000ff"),light:o(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}},d.getScale=function(t,e){return e.width&&e.width>=t+2*e.margin?e.width/(t+2*e.margin):e.scale},d.getImageWidth=function(t,e){var r=d.getScale(t,e);return Math.floor((t+2*e.margin)*r)},d.qrToImageData=function(t,e,r){for(var n=e.modules.size,o=e.modules.data,a=d.getScale(n,r),i=Math.floor((n+2*r.margin)*a),u=r.margin*a,s=[r.color.light,r.color.dark],h=0;h':"",t="',o=''+i+t+"\n","function"==typeof n&&n(null,o),o;var n,o,a,i}),y={create:w,toCanvas:m,toDataURL:v,toString:E};return t.create=w,t.default=y,t.toCanvas=m,t.toDataURL=v,t.toString=E,Object.defineProperty(t,"__esModule",{value:!0}),t}({}); \ No newline at end of file diff --git a/scratch/db_migrate.cjs b/scratch/db_migrate.cjs new file mode 100644 index 0000000..6958e49 --- /dev/null +++ b/scratch/db_migrate.cjs @@ -0,0 +1,189 @@ +const mysql = require('mysql2/promise'); +const fs = require('fs'); +require('dotenv').config(); + +function getCleanMapKey(path) { + let clean = path.replace('img/location_photo/', '').replace('.png', ''); + clean = clean.replace('서관', 'W').replace('동관', 'E'); + clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-'); + clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-'); + clean = clean.replace(/\//g, '-'); + return clean; +} + +function getLocationName(path) { + if (path.includes('IDC')) return 'IDC'; + if (path.includes('한맥빌딩')) return '한맥빌딩'; + if (path.includes('기술개발센터')) return '기술개발센터'; + return '기타'; +} + +function getLocationDetail(path, idx) { + let clean = path.replace('img/location_photo/', '').replace('.png', ''); + let parts = clean.split('/'); + let lastPart = parts[parts.length - 1]; // e.g. "서관205", "MDF_1", "서버실_1" + return `${lastPart} 구역 자리 #${idx + 1}`; +} + +async function main() { + console.log('🏁 Starting DB migration...'); + + const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + port: process.env.DB_PORT + }); + + const connection = await pool.getConnection(); + + try { + // 1. Create physical_locations table + console.log('⏳ Creating physical_locations table...'); + await connection.query(` + CREATE TABLE IF NOT EXISTS physical_locations ( + location_code VARCHAR(50) NOT NULL COMMENT '위치 식별 코드 (예: LOC-IDC-W205-001)', + location_name VARCHAR(100) NOT NULL COMMENT '물리 위치 대분류 (예: IDC 서관)', + location_detail VARCHAR(100) NOT NULL COMMENT '상세 위치/랙 번호 (예: 205호 1번 랙)', + map_image VARCHAR(150) NOT NULL COMMENT '해당 도면 파일 경로 (예: img/location_photo/IDC/서관205.png)', + map_x DECIMAL(5,2) NOT NULL COMMENT '도면 내 X 백분율 좌표', + map_y DECIMAL(5,2) NOT NULL COMMENT '도면 내 Y 백분율 좌표', + map_w DECIMAL(5,2) NOT NULL DEFAULT 4.00 COMMENT '도면 내 박스 너비(%)', + map_h DECIMAL(5,2) NOT NULL DEFAULT 4.00 COMMENT '도면 내 박스 높이(%)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (location_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + `); + console.log('✅ physical_locations table ready.'); + + // 2. Create asset_audit_pending table + console.log('⏳ Creating asset_audit_pending table...'); + await connection.query(` + CREATE TABLE IF NOT EXISTS asset_audit_pending ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_code VARCHAR(50) NOT NULL COMMENT '스캔된 자산 고유번호 (예: server_1779761946023_14)', + physical_location_code VARCHAR(50) NOT NULL COMMENT '스캔된 위치 마스터 코드 (예: LOC-IDC-W205-001)', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '상태: PENDING(대기), APPROVED(승인), REJECTED(반려)', + scanned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + processed_at TIMESTAMP NULL COMMENT '승인/반려 처리 일시', + processed_by VARCHAR(50) NULL COMMENT '처리한 관리자', + CONSTRAINT fk_audit_physical FOREIGN KEY (physical_location_code) REFERENCES physical_locations(location_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + `); + console.log('✅ asset_audit_pending table ready.'); + + // 3. Add physical_location_code to asset_location + console.log('⏳ Checking physical_location_code column in asset_location...'); + const [cols] = await connection.query('DESCRIBE asset_location'); + const hasCol = cols.some(c => c.Field === 'physical_location_code'); + if (!hasCol) { + await connection.query(` + ALTER TABLE asset_location + ADD COLUMN physical_location_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT 'physical_locations의 location_code FK' + `); + console.log('✅ physical_location_code column added with utf8mb4_unicode_ci collation.'); + } else { + console.log('ℹ️ physical_location_code column already exists. Enforcing collation...'); + await connection.query(` + ALTER TABLE asset_location + MODIFY COLUMN physical_location_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT 'physical_locations의 location_code FK' + `); + console.log('✅ physical_location_code column collation enforced.'); + } + + // Add constraint if not exists + console.log('⏳ Checking foreign key constraint fk_asset_loc_physical...'); + const [constraints] = await connection.query(` + SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_NAME = 'asset_location' + AND CONSTRAINT_NAME = 'fk_asset_loc_physical' + AND TABLE_SCHEMA = DATABASE() + `); + + if (constraints.length === 0) { + console.log('⏳ Adding foreign key constraint...'); + await connection.query(` + ALTER TABLE asset_location + ADD CONSTRAINT fk_asset_loc_physical + FOREIGN KEY (physical_location_code) REFERENCES physical_locations(location_code) + `); + console.log('✅ Foreign key constraint added.'); + } else { + console.log('ℹ️ Foreign key constraint already exists.'); + } + + // 4. Load map_config.json and migrate + console.log('⏳ Migrating map_config.json data to physical_locations...'); + if (fs.existsSync('map_config.json')) { + const mapConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}'); + let insertCount = 0; + let syncCount = 0; + + for (const [mapPath, boxes] of Object.entries(mapConfig)) { + const cleanKey = getCleanMapKey(mapPath); + const locName = getLocationName(mapPath); + + for (let i = 0; i < boxes.length; i++) { + const box = boxes[i]; + const padIdx = String(i + 1).padStart(3, '0'); + const locCode = `LOC-${cleanKey}-${padIdx}`; + const locDetail = getLocationDetail(mapPath, i); + + const bx = parseFloat(box.x); + const by = parseFloat(box.y); + const bw = parseFloat(box.w || 4.00); + const bh = parseFloat(box.h || 4.00); + + // Insert into physical_locations (ignore if duplicate) + await connection.query(` + INSERT INTO physical_locations + (location_code, location_name, location_detail, map_image, map_x, map_y, map_w, map_h) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + location_name = VALUES(location_name), + location_detail = VALUES(location_detail), + map_image = VALUES(map_image), + map_x = VALUES(map_x), + map_y = VALUES(map_y), + map_w = VALUES(map_w), + map_h = VALUES(map_h) + `, [locCode, locName, locDetail, mapPath, bx, by, bw, bh]); + + insertCount++; + + // Sync database asset if box.asset_id exists + if (box.asset_id) { + const [rows] = await connection.query( + 'SELECT id FROM asset_location WHERE asset_id = ? AND is_active = 1', + [box.asset_id] + ); + if (rows.length > 0) { + await connection.query( + 'UPDATE asset_location SET physical_location_code = ? WHERE asset_id = ? AND is_active = 1', + [locCode, box.asset_id] + ); + syncCount++; + } + } + } + } + console.log(`✅ Migrated ${insertCount} physical locations and synced ${syncCount} existing assets.`); + } else { + console.log('⚠️ map_config.json not found, skipping initial migration.'); + } + + console.log('🎉 DB Migration successfully completed!'); + } catch (err) { + console.error('❌ Migration failed:', err); + throw err; + } finally { + connection.release(); + await pool.end(); + } +} + +main().catch(err => { + process.exit(1); +}); diff --git a/scratch/test_audit.cjs b/scratch/test_audit.cjs new file mode 100644 index 0000000..a7a9d7b --- /dev/null +++ b/scratch/test_audit.cjs @@ -0,0 +1,231 @@ +const assert = require('assert'); +const http = require('http'); +const mysql = require('mysql2/promise'); +require('dotenv').config(); + +const BASE_URL = 'http://localhost:3001'; + +function request(method, path, body = null) { + return new Promise((resolve, reject) => { + const url = `${BASE_URL}${path}`; + const options = { + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + const req = http.request(url, options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve({ status: res.statusCode, body: parsed }); + } catch (e) { + resolve({ status: res.statusCode, body: data }); + } + }); + }); + req.on('error', (err) => reject(err)); + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +async function runTests() { + console.log('🧪 Starting Audit TDD Tests...'); + const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + port: process.env.DB_PORT + }); + + const connection = await pool.getConnection(); + + try { + // Clean up any test records + console.log('🧹 Cleaning up test records...'); + await connection.query("DELETE FROM asset_audit_pending WHERE asset_code LIKE 'TEST-ASSET-%'"); + + // Check if test assets exist in asset_core & asset_location + // We will use an existing asset or insert a dummy test asset + const [testAssets] = await connection.query("SELECT id FROM asset_core WHERE asset_code = 'TEST-ASSET-001'"); + let testAssetId; + if (testAssets.length === 0) { + console.log('⏳ Inserting dummy test asset...'); + testAssetId = 'test_asset_uuid_123456'; + await connection.query(` + INSERT INTO asset_core (id, asset_code, category, asset_type, asset_purpose) + VALUES (?, 'TEST-ASSET-001', 'server', 'Server', 'TDD Test Server') + `, [testAssetId]); + await connection.query(` + INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) + VALUES (?, 'Initial Location', 'Initial Detail', 'initial.png', '10.00', '10.00', 1) + `, [testAssetId]); + } else { + testAssetId = testAssets[0].id; + } + + // 1. Test GET /api/physical-locations + console.log('👉 Test 1: GET /api/physical-locations'); + const res1 = await request('GET', '/api/physical-locations'); + assert.strictEqual(res1.status, 200, 'GET /api/physical-locations should return 200'); + assert(Array.isArray(res1.body), 'Response should be an array of physical locations'); + assert(res1.body.length > 0, 'Should return at least one physical location'); + console.log(`✅ Test 1 Passed: Found ${res1.body.length} physical locations.`); + + const sampleLocation = res1.body[0].location_code; + + // 2. Test POST /api/audit/scan + console.log(`👉 Test 2: POST /api/audit/scan (Location: ${sampleLocation}, Asset: TEST-ASSET-001)`); + const res2 = await request('POST', '/api/audit/scan', { + asset_code: 'TEST-ASSET-001', + physical_location_code: sampleLocation + }); + assert.strictEqual(res2.status, 200, 'POST /api/audit/scan should return 200'); + assert.strictEqual(res2.body.success, true, 'Response success should be true'); + assert(res2.body.pending_id, 'Response should contain pending_id'); + console.log(`✅ Test 2 Passed: Pending scan registered with ID: ${res2.body.pending_id}`); + + const pendingId = res2.body.pending_id; + + // 3. Test GET /api/audit/pending + console.log('👉 Test 3: GET /api/audit/pending'); + const res3 = await request('GET', '/api/audit/pending'); + assert.strictEqual(res3.status, 200, 'GET /api/audit/pending should return 200'); + assert(Array.isArray(res3.body), 'Response should be an array'); + const pendingItem = res3.body.find(item => item.id === pendingId); + assert(pendingItem, 'Pending list should contain the newly registered scan'); + assert.strictEqual(pendingItem.asset_code, 'TEST-ASSET-001', 'Asset code should match'); + assert.strictEqual(pendingItem.physical_location_code, sampleLocation, 'Location code should match'); + assert.strictEqual(pendingItem.status, 'PENDING', 'Status should be PENDING'); + console.log('✅ Test 3 Passed: Newly registered scan found in pending list with correct details.'); + + // 4. Test POST /api/audit/approve + console.log(`👉 Test 4: POST /api/audit/approve (Pending ID: ${pendingId})`); + const res4 = await request('POST', '/api/audit/approve', { + pending_ids: [pendingId], + processed_by: 'TDD-TESTER' + }); + assert.strictEqual(res4.status, 200, 'POST /api/audit/approve should return 200'); + assert.strictEqual(res4.body.success, true, 'Response success should be true'); + console.log('✅ Test 4 Passed: Audit approved.'); + + // Verify database updates + console.log('🔍 Verifying updates in database...'); + const [pendingCheck] = await connection.query( + 'SELECT status, processed_by FROM asset_audit_pending WHERE id = ?', + [pendingId] + ); + assert.strictEqual(pendingCheck[0].status, 'APPROVED', 'Pending record status should be APPROVED'); + assert.strictEqual(pendingCheck[0].processed_by, 'TDD-TESTER', 'Processed by should match'); + + const [locationCheck] = await connection.query( + 'SELECT physical_location_code, location_photo, loc_x, loc_y FROM asset_location WHERE asset_id = ? AND is_active = 1', + [testAssetId] + ); + const [physLoc] = await connection.query( + 'SELECT map_image, map_x, map_y FROM physical_locations WHERE location_code = ?', + [sampleLocation] + ); + assert.strictEqual(locationCheck[0].physical_location_code, sampleLocation, 'Asset location code should be updated'); + assert.strictEqual(locationCheck[0].location_photo, physLoc[0].map_image, 'Asset map_image should be updated'); + assert.strictEqual(parseFloat(locationCheck[0].loc_x).toFixed(2), parseFloat(physLoc[0].map_x).toFixed(2), 'Asset map_x should be updated'); + assert.strictEqual(parseFloat(locationCheck[0].loc_y).toFixed(2), parseFloat(physLoc[0].map_y).toFixed(2), 'Asset map_y should be updated'); + console.log('✅ Database verification passed: Asset location and map coordinates updated successfully!'); + + // 5. Test GET /api/maps (Before modification) + console.log('👉 Test 5: GET /api/maps'); + const res5 = await request('GET', '/api/maps'); + assert.strictEqual(res5.status, 200, 'GET /api/maps should return 200'); + assert(typeof res5.body === 'object' && res5.body !== null, 'Response should be a map config object'); + console.log('✅ Test 5 Passed: GET /api/maps returned valid object.'); + + // 6. Test POST /api/maps/save + console.log('👉 Test 6: POST /api/maps/save'); + const testMapPath = 'img/location_photo/TDD_TEST_MAP.png'; + const testBoxes = [ + { + x: '30.50', + y: '40.25', + w: '10.00', + h: '12.00', + asset_id: testAssetId + }, + { + x: '50.00', + y: '60.00', + w: '5.00', + h: '5.00', + asset_id: null + } + ]; + + const res6 = await request('POST', '/api/maps/save', { + path: testMapPath, + boxes: testBoxes + }); + assert.strictEqual(res6.status, 200, 'POST /api/maps/save should return 200'); + assert.strictEqual(res6.body.success, true, 'Save should be successful'); + console.log('✅ Test 6 Passed: Map coordinate save triggered successfully.'); + + // Verify DB update directly for physical_locations + console.log('🔍 Verifying physical_locations update in database...'); + const [physLocCheck] = await connection.query( + 'SELECT location_code, map_x, map_y, map_w, map_h FROM physical_locations WHERE map_image = ? ORDER BY location_code', + [testMapPath] + ); + assert.strictEqual(physLocCheck.length, 2, 'Should create 2 physical locations for the test map'); + + // First location has asset_id mapped + assert.strictEqual(parseFloat(physLocCheck[0].map_x).toFixed(2), '30.50', 'First location X coord match'); + assert.strictEqual(parseFloat(physLocCheck[0].map_y).toFixed(2), '40.25', 'First location Y coord match'); + assert.strictEqual(parseFloat(physLocCheck[0].map_w).toFixed(2), '10.00', 'First location W size match'); + assert.strictEqual(parseFloat(physLocCheck[0].map_h).toFixed(2), '12.00', 'First location H size match'); + + // Asset location coordinates sync check + console.log('🔍 Verifying asset_location coordination sync in database...'); + const [assetLocSyncCheck] = await connection.query( + 'SELECT loc_x, loc_y, physical_location_code FROM asset_location WHERE asset_id = ? AND is_active = 1', + [testAssetId] + ); + assert(assetLocSyncCheck.length > 0, 'Asset location should be active'); + assert.strictEqual(parseFloat(assetLocSyncCheck[0].loc_x).toFixed(2), '30.50', 'Asset location X should sync'); + assert.strictEqual(parseFloat(assetLocSyncCheck[0].loc_y).toFixed(2), '40.25', 'Asset location Y should sync'); + assert.strictEqual(assetLocSyncCheck[0].physical_location_code, physLocCheck[0].location_code, 'Physical location code should match'); + console.log('✅ DB Verification for save: physical_locations and asset_location coordinates synced.'); + + // 7. Test GET /api/maps (After modification) + console.log('👉 Test 7: GET /api/maps (After saving)'); + const res7 = await request('GET', '/api/maps'); + assert.strictEqual(res7.status, 200, 'GET /api/maps should return 200'); + assert(res7.body[testMapPath], 'Returned config should contain the newly saved test map'); + const savedBoxes = res7.body[testMapPath]; + assert.strictEqual(savedBoxes.length, 2, 'Saved boxes count match'); + assert.strictEqual(savedBoxes[0].asset_id, testAssetId, 'First box asset_id match'); + assert.strictEqual(savedBoxes[0].x, '30.50', 'First box X match'); + assert.strictEqual(savedBoxes[1].asset_id, null, 'Second box asset_id is null'); + console.log('✅ Test 7 Passed: GET /api/maps returned updated configuration.'); + + // Clean up + console.log('🧹 Cleaning up test assets...'); + await connection.query("DELETE FROM asset_audit_pending WHERE asset_code = 'TEST-ASSET-001'"); + await connection.query("DELETE FROM asset_location WHERE asset_id = ?", [testAssetId]); + await connection.query("DELETE FROM asset_core WHERE id = ?", [testAssetId]); + await connection.query("DELETE FROM physical_locations WHERE map_image = ?", [testMapPath]); + + console.log('🎉 All TDD tests passed successfully!'); + } catch (err) { + console.error('❌ TDD Test Suite Failed:', err.message); + throw err; + } finally { + connection.release(); + await pool.end(); + } +} + +runTests().catch(() => process.exit(1)); diff --git a/server.js b/server.js index 9251ed3..4083009 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ import cors from 'cors'; import dotenv from 'dotenv'; import fs from 'fs'; -dotenv.config({ override: true }); +dotenv.config(); const app = express(); app.use(cors()); @@ -515,13 +515,67 @@ app.get('/api/generate-asset-code', async (req, res) => { } catch (err) { handleError(res, err, 'GENERATE CODE'); } }); +function getCleanMapKey(path) { + let clean = path.replace('img/location_photo/', '').replace('.png', ''); + clean = clean.replace('서관', 'W').replace('동관', 'E'); + clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-'); + clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-'); + clean = clean.replace(/\//g, '-'); + return clean; +} + +function getLocationName(path) { + if (path.includes('IDC')) return 'IDC'; + if (path.includes('한맥빌딩')) return '한맥빌딩'; + if (path.includes('기술개발센터')) return '기술개발센터'; + return '기타'; +} + +function getLocationDetail(path, idx) { + let clean = path.replace('img/location_photo/', '').replace('.png', ''); + let parts = clean.split('/'); + let lastPart = parts[parts.length - 1]; + return `${lastPart} 구역 자리 #${idx + 1}`; +} + // 6. Map Config API -app.get('/api/maps', (req, res) => { +app.get('/api/maps', async (req, res) => { try { - if (!fs.existsSync('map_config.json')) return res.json({}); - const data = fs.readFileSync('map_config.json', 'utf8'); - res.json(JSON.parse(data || '{}')); - } catch (err) { handleError(res, err, 'GET MAPS'); } + const query = ` + SELECT + pl.location_code, + pl.location_name, + pl.location_detail, + pl.map_image, + pl.map_x, + pl.map_y, + pl.map_w, + pl.map_h, + al.asset_id + FROM physical_locations pl + LEFT JOIN asset_location al ON al.physical_location_code = pl.location_code AND al.is_active = 1 + `; + const [rows] = await pool.query(query); + + const mapConfig = {}; + rows.forEach(row => { + const mapPath = row.map_image; + if (!mapConfig[mapPath]) { + mapConfig[mapPath] = []; + } + mapConfig[mapPath].push({ + x: parseFloat(row.map_x).toFixed(2), + y: parseFloat(row.map_y).toFixed(2), + w: parseFloat(row.map_w).toFixed(2), + h: parseFloat(row.map_h).toFixed(2), + asset_id: row.asset_id + }); + }); + + res.json(mapConfig); + } catch (err) { + handleError(res, err, 'GET MAPS'); + } }); // 6.5. Get Hardware Components Master List @@ -680,38 +734,284 @@ app.post('/api/maps/save', async (req, res) => { try { const { path, boxes } = req.body; if (!path) return res.status(400).json({ error: 'Path is required' }); + if (!Array.isArray(boxes)) return res.status(400).json({ error: 'Boxes must be an array' }); - // 1. Get old config to track movements - let oldConfig = {}; - if (fs.existsSync('map_config.json')) { - oldConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}'); - } - const oldBoxes = oldConfig[path] || []; - - // 2. Save new config to file - oldConfig[path] = boxes; - fs.writeFileSync('map_config.json', JSON.stringify(oldConfig, null, 2)); - - // 3. Sync Database Assets (asset_location table) connection = await pool.getConnection(); - for (const box of boxes) { + await connection.beginTransaction(); + + const cleanKey = getCleanMapKey(path); + const locName = getLocationName(path); + + // 1. Get old location codes for this map + const [oldLocs] = await connection.query( + 'SELECT location_code FROM physical_locations WHERE map_image = ?', + [path] + ); + const oldLocCodes = oldLocs.map(r => r.location_code); + + // 2. Deactivate and clear foreign key references in asset_location to these old location codes + if (oldLocCodes.length > 0) { + await connection.query( + 'UPDATE asset_location SET is_active = 0, deactivated_at = NOW(), physical_location_code = NULL WHERE physical_location_code IN (?)', + [oldLocCodes] + ); + } + + // 3. Delete old physical locations for this map + await connection.query( + 'DELETE FROM physical_locations WHERE map_image = ?', + [path] + ); + + // 4. Insert new physical locations and setup asset_location mappings + for (let i = 0; i < boxes.length; i++) { + const box = boxes[i]; + const padIdx = String(i + 1).padStart(3, '0'); + const locCode = `LOC-${cleanKey}-${padIdx}`; + const locDetail = getLocationDetail(path, i); + + // Insert physical location + await connection.query(` + INSERT INTO physical_locations + (location_code, location_name, location_detail, map_image, map_x, map_y, map_w, map_h) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, [locCode, locName, locDetail, path, box.x, box.y, box.w, box.h]); + + // If asset_id is mapped, update asset_location if (box.asset_id) { - console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`); + // Deactivate old active locations for this asset await connection.query( - 'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1', - [box.x, box.y, box.asset_id] + 'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', + [box.asset_id] ); + + // Insert new active location mapping + await connection.query(` + INSERT INTO asset_location + (asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + `, [box.asset_id, locName, locDetail, path, box.x, box.y, locCode]); } } + await connection.commit(); res.json({ success: true, message: 'Map and Database synced successfully' }); } catch (err) { + if (connection) await connection.rollback(); handleError(res, err, 'SAVE MAPS SYNC'); } finally { if (connection) connection.release(); } }); +// ========================================== +// 8. QR Asset Audit & Scan APIs +// ========================================== + +// GET all physical locations +app.get('/api/physical-locations', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM physical_locations ORDER BY location_code'); + res.json(rows); + } catch (err) { + handleError(res, err, 'GET PHYSICAL LOCATIONS'); + } +}); + +// POST register scan (mobile) +app.post('/api/audit/scan', async (req, res) => { + let connection; + try { + const { asset_code, physical_location_code } = req.body; + if (!asset_code || !physical_location_code) { + return res.status(400).json({ error: 'asset_code and physical_location_code are required' }); + } + + connection = await pool.getConnection(); + + // Verify if asset exists + const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]); + if (assets.length === 0) { + return res.status(404).json({ error: `Asset with code ${asset_code} not found` }); + } + + // Insert pending audit record + const [result] = await connection.query( + 'INSERT INTO asset_audit_pending (asset_code, physical_location_code, status) VALUES (?, ?, ?)', + [asset_code, physical_location_code, 'PENDING'] + ); + + res.json({ success: true, pending_id: result.insertId }); + } catch (err) { + handleError(res, err, 'REGISTER SCAN'); + } finally { + if (connection) connection.release(); + } +}); + +// GET pending audits list (admin) +app.get('/api/audit/pending', async (req, res) => { + try { + const [rows] = await pool.query(` + SELECT + ap.*, + c.id AS asset_id, + c.asset_purpose, + c.asset_type, + pl.location_name, + pl.location_detail, + pl.map_image, + l.location AS old_location, + l.location_detail AS old_location_detail + FROM asset_audit_pending ap + JOIN asset_core c ON c.asset_code = ap.asset_code + JOIN physical_locations pl ON pl.location_code = ap.physical_location_code + LEFT JOIN asset_location l ON l.asset_id = c.id AND l.is_active = 1 + ORDER BY ap.scanned_at DESC + `); + res.json(rows); + } catch (err) { + handleError(res, err, 'GET PENDING AUDITS'); + } +}); + +// POST approve audits (admin) +app.post('/api/audit/approve', async (req, res) => { + let connection; + try { + const { pending_ids, processed_by } = req.body; + if (!Array.isArray(pending_ids) || pending_ids.length === 0) { + return res.status(400).json({ error: 'pending_ids must be a non-empty array' }); + } + + connection = await pool.getConnection(); + await connection.beginTransaction(); + + let mapConfigChanged = false; + let mapConfig = {}; + if (fs.existsSync('map_config.json')) { + mapConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}'); + } + + for (const pendingId of pending_ids) { + // 1. Get pending scan details + const [pendings] = await connection.query( + 'SELECT asset_code, physical_location_code FROM asset_audit_pending WHERE id = ? AND status = ?', + [pendingId, 'PENDING'] + ); + if (pendings.length === 0) continue; + + const { asset_code, physical_location_code } = pendings[0]; + + // 2. Get asset ID + const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]); + if (assets.length === 0) continue; + const assetId = assets[0].id; + + // 3. Get physical location details + const [locations] = await connection.query( + 'SELECT location_name, location_detail, map_image, map_x, map_y FROM physical_locations WHERE location_code = ?', + [physical_location_code] + ); + if (locations.length === 0) continue; + const loc = locations[0]; + + // 4. Deactivate old active locations for this asset + await connection.query( + 'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', + [assetId] + ); + + // 5. Insert new active location + await connection.query(` + INSERT INTO asset_location + (asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + `, [assetId, loc.location_name, loc.location_detail, loc.map_image, loc.map_x, loc.map_y, physical_location_code]); + + // 6. Update pending audit status + await connection.query( + 'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ?', + ['APPROVED', processed_by || 'ADMIN', pendingId] + ); + + // 7. Sync map_config.json + // Remove asset from any other map coordinates + for (const [mapPath, boxes] of Object.entries(mapConfig)) { + let changed = false; + const newBoxes = boxes.map(b => { + if (b.asset_id === assetId) { + changed = true; + return { ...b, asset_id: null }; + } + return b; + }); + if (changed) { + mapConfig[mapPath] = newBoxes; + mapConfigChanged = true; + } + } + + // Add asset to the new map coordinate box matching map_image, map_x, map_y + if (mapConfig[loc.map_image]) { + const ax = parseFloat(loc.map_x); + const ay = parseFloat(loc.map_y); + const boxes = mapConfig[loc.map_image]; + const matchedBox = boxes.find(b => { + const bx = parseFloat(b.x); + const by = parseFloat(b.y); + return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1; + }); + if (matchedBox) { + matchedBox.asset_id = assetId; + mapConfigChanged = true; + } + } + } + + if (mapConfigChanged) { + fs.writeFileSync('map_config.json', JSON.stringify(mapConfig, null, 2)); + } + + await connection.commit(); + res.json({ success: true, message: 'Audits approved successfully' }); + } catch (err) { + if (connection) await connection.rollback(); + handleError(res, err, 'APPROVE AUDITS'); + } finally { + if (connection) connection.release(); + } +}); + +// POST reject audits (admin) +app.post('/api/audit/reject', async (req, res) => { + let connection; + try { + const { pending_ids, processed_by } = req.body; + if (!Array.isArray(pending_ids) || pending_ids.length === 0) { + return res.status(400).json({ error: 'pending_ids must be a non-empty array' }); + } + + connection = await pool.getConnection(); + await connection.beginTransaction(); + + for (const pendingId of pending_ids) { + await connection.query( + 'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ? AND status = ?', + ['REJECTED', processed_by || 'ADMIN', pendingId, 'PENDING'] + ); + } + + await connection.commit(); + res.json({ success: true, message: 'Audits rejected successfully' }); + } catch (err) { + if (connection) await connection.rollback(); + handleError(res, err, 'REJECT AUDITS'); + } finally { + if (connection) connection.release(); + } +}); + // 7. File Upload API (Base64) app.post('/api/upload', (req, res) => { try { @@ -736,6 +1036,6 @@ app.post('/api/upload', (req, res) => { } }); -app.listen(3000, '0.0.0.0', () => { - console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)'); +app.listen(process.env.PORT || 3000, '0.0.0.0', () => { + console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`); }); diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index 03ea7a1..906baac 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -11,6 +11,7 @@ import { } from './ModalUtils'; import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData'; import { BaseModal } from './BaseModal'; +import { QRPrinter } from '../../core/qr_print'; /** * 하드웨어 자산 상세 모달 (Styled Main Edition) @@ -30,9 +31,10 @@ class HwAssetModal extends BaseModal {