xuất tài sản

This commit is contained in:
2026-05-06 16:04:56 +07:00
parent 8b2a9d7afe
commit d88aa39bd6
4 changed files with 639 additions and 52 deletions

View File

@@ -1724,6 +1724,27 @@ async function createTables() {
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Asset Export History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN
CREATE TABLE AssetExportHistory (
ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ExportQuantity INT NOT NULL DEFAULT 1,
ProjectName NVARCHAR(150) NULL,
CustodianName NVARCHAR(100) NOT NULL,
ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
ExportedDate 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
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
@@ -1783,6 +1804,14 @@ async function createTables() {
console.error('AssetBorrowRequests index creation error:', err.message);
}
// Ensure AssetExportHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId') CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_ExportedDate') CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);`);
} catch (err) {
console.error('AssetExportHistory index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
@@ -1820,6 +1849,34 @@ async function createTables() {
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetCode') IS NULL ALTER TABLE AssetExportHistory ADD AssetCode NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetName') IS NULL ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportQuantity') IS NULL ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ProjectName') IS NULL ALTER TABLE AssetExportHistory ADD ProjectName NVARCHAR(150) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CustodianName') IS NULL ALTER TABLE AssetExportHistory ADD CustodianName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedByName') IS NULL ALTER TABLE AssetExportHistory ADD ExportedByName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportNote') IS NULL ALTER TABLE AssetExportHistory ADD ExportNote NVARCHAR(1000) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedBy') IS NULL ALTER TABLE AssetExportHistory ADD CreatedBy INT NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedDate') IS NULL ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedDate') IS NULL ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','UpdatedDate') IS NULL ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE());`);
await pool.request().query(`
IF NOT EXISTS (
SELECT 1
FROM sys.foreign_key_columns fkc
INNER JOIN sys.columns c
ON c.object_id = fkc.parent_object_id
AND c.column_id = fkc.parent_column_id
WHERE fkc.parent_object_id = OBJECT_ID('dbo.AssetExportHistory')
AND c.name = 'CreatedBy'
)
AND COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NOT NULL
BEGIN
ALTER TABLE AssetExportHistory
ADD CONSTRAINT FK_AssetExportHistory_CreatedBy
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
END
`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
@@ -3711,10 +3768,19 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary)
.input('exportInPeriod', sql.Int, currentBorrowed + requestQuantity)
.input('endingBalance', sql.Int, Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- (currentBorrowed + requestQuantity),
0
))
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
@@ -3735,13 +3801,22 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
}
const borrowerSummary = decreased.summary || null;
const remainingBorrowed = decreased.entries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const quantity = parseNonNegativeInteger(targetRequest.Quantity, 0);
const importInPeriod = parseNonNegativeInteger(targetRequest.ImportInPeriod, 0);
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, remainingBorrowed)
.input('endingBalance', sql.Int, Math.max(quantity + importInPeriod - remainingBorrowed, 0))
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
@@ -3957,6 +4032,215 @@ app.get('/api/assets/:id', async (req, res) => {
}
});
app.get('/api/asset-export-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)
ExportHistoryId,
AssetId,
AssetCode,
AssetName,
ExportQuantity,
ProjectName,
CustodianName,
ExportedByName,
ExportNote,
CreatedBy,
ExportedDate,
CreatedDate,
UpdatedDate
FROM AssetExportHistory
ORDER BY ExportedDate DESC, ExportHistoryId 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/export', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const assetId = Number(req.params.id);
const exportQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const borrowerName = String(req.body?.borrowerName || req.body?.custodianName || '').trim();
const projectName = String(req.body?.projectName || '').trim() || null;
const exportNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const exportedByName = await getUserDisplayNameById(createdBy) || String(req.headers['x-user-role'] || '').trim() || 'Unknown';
const exportedDate = new Date();
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Asset id is invalid' });
}
if (exportQuantity <= 0) {
return res.status(400).json({ success: false, message: 'So luong xuat phai lon hon 0' });
}
if (!borrowerName) {
return res.status(400).json({ success: false, message: 'Nguoi muon la bat buoc' });
}
if (!projectName) {
return res.status(400).json({ success: false, message: 'Du an xuat la bat buoc' });
}
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,
Custodian,
Borrower
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 quantity = parseNonNegativeInteger(asset.Quantity, 0);
const importInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0);
const existingBorrowerEntries = parseBorrowerEntries(asset.Borrower);
const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const storedExportInPeriod = parseOptionalNonNegativeInteger(asset.ExportInPeriod);
const baseExportInPeriod = storedExportInPeriod !== null ? storedExportInPeriod : previousBorrowerExport;
const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance);
const baseEndingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(quantity + importInPeriod - baseExportInPeriod, 0);
if (baseEndingBalance <= 0) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky, khong the xuat them' });
}
if (exportQuantity > baseEndingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `So luong xuat (${exportQuantity}) vuot qua ton cuoi ky (${baseEndingBalance})`
});
}
const borrowerSummary = mergeBorrowerEntries(asset.Borrower, borrowerName, exportQuantity);
const nextBorrowerEntries = parseBorrowerEntries(borrowerSummary);
const nextBorrowerExport = nextBorrowerEntries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const exportDelta = nextBorrowerExport - previousBorrowerExport;
const nextExportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0);
const nextEndingBalance = Math.max(baseEndingBalance - exportDelta, 0);
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('project', sql.NVarChar, projectName)
.input('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, nextExportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance)
.input('exportedBy', sql.NVarChar, exportedByName)
.query(`
UPDATE AssetInventory
SET Project = @project,
Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
const historyCustodianName = String(asset.Custodian || '').trim() || '-';
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('exportQuantity', sql.Int, exportQuantity)
.input('projectName', sql.NVarChar, projectName)
.input('custodianName', sql.NVarChar, historyCustodianName)
.input('exportedByName', sql.NVarChar, exportedByName)
.input('exportNote', sql.NVarChar, exportNote)
.input('createdBy', sql.Int, createdBy)
.input('exportedDate', sql.DateTime, exportedDate)
.query(`
INSERT INTO AssetExportHistory (
AssetId,
AssetCode,
AssetName,
ExportQuantity,
ProjectName,
CustodianName,
ExportedByName,
ExportNote,
CreatedBy,
ExportedDate
)
OUTPUT
INSERTED.ExportHistoryId,
INSERTED.AssetId,
INSERTED.AssetCode,
INSERTED.AssetName,
INSERTED.ExportQuantity,
INSERTED.ProjectName,
INSERTED.CustodianName,
INSERTED.ExportedByName,
INSERTED.ExportNote,
INSERTED.CreatedBy,
INSERTED.ExportedDate,
INSERTED.CreatedDate,
INSERTED.UpdatedDate
VALUES (
@assetId,
@assetCode,
@assetName,
@exportQuantity,
@projectName,
@custodianName,
@exportedByName,
@exportNote,
@createdBy,
@exportedDate
)
`);
await transaction.commit();
res.json({
success: true,
message: 'Xuat tai san thanh cong',
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', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);