feat: QR 자산 스캔 점검, 모바일 웹뷰 및 관리자 승인 시스템 구현 (DB 기반 맵 좌표 저장 단일화 포함)
This commit is contained in:
348
server.js
348
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)`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user