case hỏng

This commit is contained in:
2026-05-15 10:51:39 +07:00
parent 12c6380ceb
commit 41e523ff35
4 changed files with 815 additions and 4 deletions

View File

@@ -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 {