24/04/2026 - mã tài sản
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user