feat: QR 자산 스캔 점검, 모바일 웹뷰 및 관리자 승인 시스템 구현 (DB 기반 맵 좌표 저장 단일화 포함)

This commit is contained in:
이태훈
2026-06-23 16:39:14 +09:00
parent 9f165faf13
commit f36e8e93e2
21 changed files with 2357 additions and 46 deletions

189
scratch/db_migrate.cjs Normal file
View File

@@ -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);
});

231
scratch/test_audit.cjs Normal file
View File

@@ -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));