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

348
server.js
View File

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