diff --git a/backend/server.js b/backend/server.js index 1dcb301..c442192 100644 --- a/backend/server.js +++ b/backend/server.js @@ -692,6 +692,19 @@ function normalizeImportToken(value) { .replace(/[^a-z0-9]/g, ''); } +function normalizeAssetDamageType(value) { + const normalized = normalizeImportToken(value); + if (['disposed', 'dispose', 'disposal', 'thanhly', 'liquidated', 'liquidation'].includes(normalized)) { + return 'disposed'; + } + + return 'damaged'; +} + +function getAssetDamageTypeLabel(value) { + return normalizeAssetDamageType(value) === 'disposed' ? 'Thanh lý' : 'Hỏng'; +} + function isHeaderLikeAssetImportRow(row = {}) { const headerTokens = new Set([ 'stt', @@ -1841,6 +1854,41 @@ async function createTables() { FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE ) END`, + + // Asset Damage/Disposal History Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory') + BEGIN + CREATE TABLE AssetDamageDisposalHistory ( + DamageHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ActionType NVARCHAR(20) NOT NULL, + ActionLabel NVARCHAR(50) NOT NULL, + ActionQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50) NULL, + PreviousQuantity INT NOT NULL DEFAULT 0, + NextQuantity INT NOT NULL DEFAULT 0, + PreviousImportInPeriod INT NOT NULL DEFAULT 0, + NextImportInPeriod INT NOT NULL DEFAULT 0, + PreviousExportInPeriod INT NOT NULL DEFAULT 0, + NextExportInPeriod INT NOT NULL DEFAULT 0, + PreviousEndingBalance INT NOT NULL DEFAULT 0, + NextEndingBalance INT NOT NULL DEFAULT 0, + PreviousNewQuantity INT NOT NULL DEFAULT 0, + NextNewQuantity INT NOT NULL DEFAULT 0, + PreviousUsedQuantity INT NOT NULL DEFAULT 0, + NextUsedQuantity INT NOT NULL DEFAULT 0, + ActionNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + CreatedByName NVARCHAR(100) NULL, + ActionDate DATETIME NOT NULL DEFAULT GETDATE(), + CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), + UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -1911,6 +1959,15 @@ async function createTables() { console.error('AssetExportHistory index creation error:', err.message); } + // Ensure AssetDamageDisposalHistory indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);`); + } catch (err) { + console.error('AssetDamageDisposalHistory index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( @@ -4979,6 +5036,291 @@ app.get('/api/asset-export-history', requireAssetOrAdmin, async (req, res) => { } }); +app.get('/api/asset-damage-disposal-history', requireAssetOrAdmin, async (req, res) => { + try { + const limit = Math.min(parsePositiveInteger(req.query.limit, 300), 2000); + const result = await pool.request() + .input('limit', sql.Int, limit) + .query(` + SELECT TOP (@limit) + DamageHistoryId, + AssetId, + AssetCode, + AssetName, + ActionType, + ActionLabel, + ActionQuantity, + Unit, + PreviousQuantity, + NextQuantity, + PreviousImportInPeriod, + NextImportInPeriod, + PreviousExportInPeriod, + NextExportInPeriod, + PreviousEndingBalance, + NextEndingBalance, + PreviousNewQuantity, + NextNewQuantity, + PreviousUsedQuantity, + NextUsedQuantity, + ActionNote, + CreatedBy, + CreatedByName, + ActionDate, + CreatedDate, + UpdatedDate + FROM AssetDamageDisposalHistory + ORDER BY ActionDate DESC, DamageHistoryId DESC + `); + + res.json({ + success: true, + data: Array.isArray(result.recordset) ? result.recordset : [] + }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/assets/:id/damage-disposal', requireAssetOrAdmin, async (req, res) => { + let transaction; + + try { + const assetId = Number(req.params.id); + const actionType = normalizeAssetDamageType(req.body?.actionType || req.body?.reason); + const actionLabel = getAssetDamageTypeLabel(actionType); + const actionQuantity = parseNonNegativeInteger(req.body?.quantity, 0); + const actionNote = String(req.body?.note || '').trim() || null; + const createdBy = getUserIdFromRequest(req); + const createdByName = await getUserDisplayNameById(createdBy); + const actionDate = new Date(); + + if (!Number.isInteger(assetId) || assetId <= 0) { + return res.status(400).json({ success: false, message: 'Asset id is invalid' }); + } + + if (actionQuantity <= 0) { + return res.status(400).json({ success: false, message: 'Số lượng phải lớn hơn 0' }); + } + + transaction = new sql.Transaction(pool); + await transaction.begin(); + + const assetResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .query(` + SELECT TOP 1 + AssetId, + AssetCode, + AssetName, + Quantity, + ImportInPeriod, + ExportInPeriod, + EndingBalance, + NewQuantity, + UsedQuantity, + Unit + FROM AssetInventory WITH (UPDLOCK, ROWLOCK) + WHERE AssetId = @assetId + `); + + const asset = assetResult.recordset?.[0]; + if (!asset) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Asset not found' }); + } + + const previousQuantity = parseNonNegativeInteger(asset.Quantity, 0); + const previousImportInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0); + const previousExportInPeriod = parseNonNegativeInteger(asset.ExportInPeriod, 0); + const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance); + const previousEndingBalance = storedEndingBalance !== null + ? storedEndingBalance + : Math.max(previousQuantity + previousImportInPeriod - previousExportInPeriod, 0); + const previousStockBuckets = normalizeAssetStockBuckets( + previousEndingBalance, + parseOptionalNonNegativeInteger(asset.NewQuantity) ?? previousEndingBalance, + parseOptionalNonNegativeInteger(asset.UsedQuantity) ?? 0 + ); + + if (previousEndingBalance <= 0) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Tài sản đã hết tồn cuối kỳ' }); + } + + if (actionQuantity > previousEndingBalance) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Số lượng ${actionLabel.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${previousEndingBalance})` + }); + } + + let remainingSourceReduction = actionQuantity; + const reduceFromQuantity = Math.min(previousQuantity, remainingSourceReduction); + const nextQuantity = Math.max(previousQuantity - reduceFromQuantity, 0); + remainingSourceReduction -= reduceFromQuantity; + const reduceFromImport = Math.min(previousImportInPeriod, remainingSourceReduction); + const nextImportInPeriod = Math.max(previousImportInPeriod - reduceFromImport, 0); + + const nextExportInPeriod = previousExportInPeriod; + const nextEndingBalance = Math.max(nextQuantity + nextImportInPeriod - nextExportInPeriod, 0); + + let nextUsedQuantity = previousStockBuckets.usedQuantity; + let nextNewQuantity = previousStockBuckets.newQuantity; + let remainingStockReduction = actionQuantity; + const reduceFromUsed = Math.min(nextUsedQuantity, remainingStockReduction); + nextUsedQuantity -= reduceFromUsed; + remainingStockReduction -= reduceFromUsed; + const reduceFromNew = Math.min(nextNewQuantity, remainingStockReduction); + nextNewQuantity -= reduceFromNew; + + const nextStockBuckets = normalizeAssetStockBuckets(nextEndingBalance, nextNewQuantity, nextUsedQuantity); + const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextExportInPeriod); + + await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('quantity', sql.Int, nextQuantity) + .input('importInPeriod', sql.Int, nextImportInPeriod) + .input('endingBalance', sql.Int, nextEndingBalance) + .input('newQuantity', sql.Int, nextStockBuckets.newQuantity) + .input('usedQuantity', sql.Int, nextStockBuckets.usedQuantity) + .input('status', sql.NVarChar, nextStatus) + .query(` + UPDATE AssetInventory + SET Quantity = @quantity, + ImportInPeriod = @importInPeriod, + EndingBalance = @endingBalance, + NewQuantity = @newQuantity, + UsedQuantity = @usedQuantity, + Status = @status, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + + const historyResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('assetCode', sql.NVarChar, String(asset.AssetCode || '').trim()) + .input('assetName', sql.NVarChar, String(asset.AssetName || '').trim()) + .input('actionType', sql.NVarChar, actionType) + .input('actionLabel', sql.NVarChar, actionLabel) + .input('actionQuantity', sql.Int, actionQuantity) + .input('unit', sql.NVarChar, String(asset.Unit || '').trim() || null) + .input('previousQuantity', sql.Int, previousQuantity) + .input('nextQuantity', sql.Int, nextQuantity) + .input('previousImportInPeriod', sql.Int, previousImportInPeriod) + .input('nextImportInPeriod', sql.Int, nextImportInPeriod) + .input('previousExportInPeriod', sql.Int, previousExportInPeriod) + .input('nextExportInPeriod', sql.Int, nextExportInPeriod) + .input('previousEndingBalance', sql.Int, previousEndingBalance) + .input('nextEndingBalance', sql.Int, nextEndingBalance) + .input('previousNewQuantity', sql.Int, previousStockBuckets.newQuantity) + .input('nextNewQuantity', sql.Int, nextStockBuckets.newQuantity) + .input('previousUsedQuantity', sql.Int, previousStockBuckets.usedQuantity) + .input('nextUsedQuantity', sql.Int, nextStockBuckets.usedQuantity) + .input('actionNote', sql.NVarChar, actionNote) + .input('createdBy', sql.Int, createdBy) + .input('createdByName', sql.NVarChar, createdByName) + .input('actionDate', sql.DateTime, actionDate) + .query(` + INSERT INTO AssetDamageDisposalHistory ( + AssetId, + AssetCode, + AssetName, + ActionType, + ActionLabel, + ActionQuantity, + Unit, + PreviousQuantity, + NextQuantity, + PreviousImportInPeriod, + NextImportInPeriod, + PreviousExportInPeriod, + NextExportInPeriod, + PreviousEndingBalance, + NextEndingBalance, + PreviousNewQuantity, + NextNewQuantity, + PreviousUsedQuantity, + NextUsedQuantity, + ActionNote, + CreatedBy, + CreatedByName, + ActionDate + ) + OUTPUT + INSERTED.DamageHistoryId, + INSERTED.AssetId, + INSERTED.AssetCode, + INSERTED.AssetName, + INSERTED.ActionType, + INSERTED.ActionLabel, + INSERTED.ActionQuantity, + INSERTED.Unit, + INSERTED.PreviousQuantity, + INSERTED.NextQuantity, + INSERTED.PreviousImportInPeriod, + INSERTED.NextImportInPeriod, + INSERTED.PreviousExportInPeriod, + INSERTED.NextExportInPeriod, + INSERTED.PreviousEndingBalance, + INSERTED.NextEndingBalance, + INSERTED.PreviousNewQuantity, + INSERTED.NextNewQuantity, + INSERTED.PreviousUsedQuantity, + INSERTED.NextUsedQuantity, + INSERTED.ActionNote, + INSERTED.CreatedBy, + INSERTED.CreatedByName, + INSERTED.ActionDate, + INSERTED.CreatedDate, + INSERTED.UpdatedDate + VALUES ( + @assetId, + @assetCode, + @assetName, + @actionType, + @actionLabel, + @actionQuantity, + @unit, + @previousQuantity, + @nextQuantity, + @previousImportInPeriod, + @nextImportInPeriod, + @previousExportInPeriod, + @nextExportInPeriod, + @previousEndingBalance, + @nextEndingBalance, + @previousNewQuantity, + @nextNewQuantity, + @previousUsedQuantity, + @nextUsedQuantity, + @actionNote, + @createdBy, + @createdByName, + @actionDate + ) + `); + + await transaction.commit(); + + res.json({ + success: true, + message: `Đã ghi nhận tài sản ${actionLabel.toLowerCase()}`, + data: historyResult.recordset?.[0] || null + }); + } catch (err) { + if (transaction) { + try { + await transaction.rollback(); + } catch (_rollbackErr) { + // Ignore rollback error, respond original error below. + } + } + res.status(500).json({ success: false, message: err.message }); + } +}); + app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => { let transaction; try { diff --git a/database/setup.sql b/database/setup.sql index a4f6e9f..e68ef25 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -484,7 +484,45 @@ BEGIN END -- =========================================== --- 9. CREATE AUDIT LOG TABLE +-- 9. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory') +BEGIN + CREATE TABLE AssetDamageDisposalHistory ( + DamageHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ActionType NVARCHAR(20) NOT NULL, + ActionLabel NVARCHAR(50) NOT NULL, + ActionQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50) NULL, + PreviousQuantity INT NOT NULL DEFAULT 0, + NextQuantity INT NOT NULL DEFAULT 0, + PreviousImportInPeriod INT NOT NULL DEFAULT 0, + NextImportInPeriod INT NOT NULL DEFAULT 0, + PreviousExportInPeriod INT NOT NULL DEFAULT 0, + NextExportInPeriod INT NOT NULL DEFAULT 0, + PreviousEndingBalance INT NOT NULL DEFAULT 0, + NextEndingBalance INT NOT NULL DEFAULT 0, + PreviousNewQuantity INT NOT NULL DEFAULT 0, + NextNewQuantity INT NOT NULL DEFAULT 0, + PreviousUsedQuantity INT NOT NULL DEFAULT 0, + NextUsedQuantity INT NOT NULL DEFAULT 0, + ActionNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + CreatedByName NVARCHAR(100) NULL, + ActionDate DATETIME NOT NULL DEFAULT GETDATE(), + CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), + UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ); + PRINT 'Table AssetDamageDisposalHistory created successfully.'; +END + +-- =========================================== +-- 10. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -503,7 +541,7 @@ BEGIN END -- =========================================== --- 10. CREATE INDEXES +-- 11. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -590,10 +628,25 @@ BEGIN CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 11. INSERT INITIAL DATA +-- 12. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists diff --git a/public/js/app.js b/public/js/app.js index 7662b1b..fa8f752 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -53,6 +53,7 @@ class AccountManager { this.assetProjects = []; this.assetProjectSearchTerm = ''; this.assetExportHistories = []; + this.assetDamageHistories = []; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; @@ -71,6 +72,7 @@ class AccountManager { this.assetBorrowAutoRefreshTimer = undefined; this.pendingAssetRequestDeleteConfirmResolver = undefined; this.pendingBulkAssetDeleteConfirmResolver = undefined; + this.pendingAssetDamageId = undefined; } configureNotifications() { @@ -1072,6 +1074,114 @@ class AccountManager { this.renderAssetExportHistoryModal(); } + normalizeAssetDamageType(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'disposed' || normalized === 'thanh_ly' || normalized === 'thanh ly' + ? 'disposed' + : 'damaged'; + } + + getAssetDamageTypeMeta(value) { + const type = this.normalizeAssetDamageType(value); + if (type === 'disposed') { + return { + value: 'disposed', + label: 'Thanh lý', + className: 'bg-slate-100 text-slate-700 border border-slate-200' + }; + } + + return { + value: 'damaged', + label: 'Hỏng', + className: 'bg-red-100 text-red-700 border border-red-200' + }; + } + + async fetchAssetDamageHistories(limit = 300) { + try { + const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300; + const res = await fetch(`${this.apiBase}/asset-damage-disposal-history?limit=${safeLimit}`, { + headers: this.getAuthHeaders(false) + }); + const data = await res.json(); + if (data.success) { + this.assetDamageHistories = Array.isArray(data.data) ? data.data : []; + } else { + console.error('Load asset damage/disposal history failed:', data.message); + } + } catch (err) { + console.error('Fetch asset damage/disposal history error:', err); + } + } + + buildAssetDamageHistoryRowsHtml(rows = []) { + if (!Array.isArray(rows) || rows.length === 0) { + return ` +