24/04/2026 - mã tài sản

This commit is contained in:
2026-04-24 13:56:12 +07:00
parent 9526628334
commit 3961514f6c
5 changed files with 1048 additions and 52 deletions

View File

@@ -209,6 +209,54 @@ async function getUserDisplayNameById(userId) {
}
}
function normalizeDepartmentName(value) {
return String(value || '').trim();
}
async function syncAssetDepartmentsFromInventory() {
if (!pool) {
return;
}
await pool.request().query(`
WITH SourceDepartments AS (
SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName
FROM AssetInventory
WHERE Department IS NOT NULL
AND LTRIM(RTRIM(Department)) <> ''
)
INSERT INTO AssetDepartments (DepartmentName)
SELECT source.DepartmentName
FROM SourceDepartments source
WHERE NOT EXISTS (
SELECT 1
FROM AssetDepartments target
WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName)
);
`);
}
async function ensureDepartmentExists(departmentName) {
const normalized = normalizeDepartmentName(departmentName);
if (!normalized || !pool) {
return;
}
await pool.request()
.input('departmentName', sql.NVarChar, normalized)
.query(`
IF NOT EXISTS (
SELECT 1
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
)
BEGIN
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
END
`);
}
function parsePositiveInteger(value, fallback = 1) {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) {
@@ -678,6 +726,44 @@ function generateImportAssetCodeFromRow(mapped, rowNumber = 0) {
return `IMP-${base}-${suffix}`;
}
function generateManualAssetCodeFromPayload(payload = {}) {
const fromModel = sanitizeAssetCodeToken(payload.model);
const fromSerial = sanitizeAssetCodeToken(payload.serialNumber);
const fromName = sanitizeAssetCodeToken(payload.assetName);
const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32);
const now = new Date();
const timestamp = [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(now.getMilliseconds()).padStart(3, '0')
].join('');
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`;
}
async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = generateManualAssetCodeFromPayload(payload);
const existed = await pool.request()
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
}
throw new Error('Cannot generate unique asset code');
}
function finalizeImportedAssetPayload(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.assetName) {
@@ -1246,6 +1332,17 @@ async function createTables() {
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Asset Departments Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
@@ -1276,10 +1373,18 @@ async function createTables() {
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`);
} catch (err) {
console.error('AssetInventory index creation error:', err.message);
}
// Ensure AssetDepartments indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`);
} catch (err) {
console.error('AssetDepartments index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
@@ -1324,6 +1429,13 @@ async function createTables() {
console.error('Column addition error (Applications):', err.message);
}
// Sync legacy departments from AssetInventory to AssetDepartments
try {
await syncAssetDepartmentsFromInventory();
} catch (err) {
console.error('AssetDepartments sync error:', err.message);
}
// Insert initial admin user
try {
const adminPasswordHash = await hashPassword('admin');
@@ -2341,6 +2453,224 @@ app.delete('/api/accounts/:id', async (req, res) => {
}
});
// ==========================================
// API ROUTES - Asset Departments
// ==========================================
app.get('/api/asset-departments', async (req, res) => {
try {
await syncAssetDepartmentsFromInventory();
const result = await pool.request().query(`
SELECT
d.DepartmentId,
d.DepartmentName,
d.CreatedDate,
d.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetDepartments d
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName)))
GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate
ORDER BY d.DepartmentName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
try {
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const existed = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
const inserted = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
SELECT SCOPE_IDENTITY() AS DepartmentId;
`);
res.json({
success: true,
message: 'Đã thêm phòng ban',
departmentId: inserted.recordset[0]?.DepartmentId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
try {
const departmentId = Number(req.params.id);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' });
}
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const currentDepartment = currentResult.recordset[0];
const currentName = String(currentDepartment.DepartmentName || '').trim();
if (currentName.toLowerCase() === departmentName.toLowerCase()) {
return res.json({ success: true, message: 'Đã cập nhật phòng ban' });
}
const duplicated = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE DepartmentId <> @departmentId
AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetDepartments
SET DepartmentName = @departmentName,
UpdatedDate = GETDATE()
WHERE DepartmentId = @departmentId
`);
await new sql.Request(transaction)
.input('oldDepartmentName', sql.NVarChar, currentName)
.input('newDepartmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = @newDepartmentName,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName)
`);
await transaction.commit();
res.json({ success: true, message: 'Đã cập nhật phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
try {
const departmentId = Number(req.params.id);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = NULL,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName)
`);
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.query(`
DELETE FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
await transaction.commit();
res.json({ success: true, message: 'Đã xóa phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Asset Inventory
// ==========================================
@@ -2391,10 +2721,16 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
if (!payload.assetCode || !payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
if (!payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset name is required' });
}
if (!payload.assetCode) {
payload.assetCode = await generateUniqueManualAssetCode(payload);
}
await ensureDepartmentExists(payload.department);
const result = await pool.request()
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
@@ -2451,6 +2787,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
}
await ensureDepartmentExists(payload.department);
await pool.request()
.input('assetId', sql.Int, req.params.id)
.input('assetCode', sql.NVarChar, payload.assetCode)
@@ -2561,7 +2899,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
const normalizedRows = incomingRows
.map(row => normalizeAssetPayload(row))
.map((row, rowIndex) => {
const normalized = normalizeAssetPayload(row);
if (!normalized.assetCode && normalized.assetName) {
normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1);
}
return normalized;
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => row.assetCode && row.assetName);
@@ -2648,6 +2992,7 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
}
await transaction.commit();
await syncAssetDepartmentsFromInventory();
res.json({
success: true,