feat: implement web-based QR label print and audit endpoints with TDD tests

This commit is contained in:
이태훈
2026-06-23 14:52:08 +09:00
parent 9f165faf13
commit e3d42189a4
11 changed files with 1190 additions and 9 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_unicode_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_unicode_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_unicode_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_unicode_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);
});