This commit is contained in:
2026-05-15 09:56:33 +07:00
parent 927317a87e
commit 4aa4ac0d57
4 changed files with 950 additions and 36 deletions

View File

@@ -674,6 +674,9 @@ function normalizeAssetRequestStatus(value) {
if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') {
return 'approved';
}
if (normalized === 'returned' || normalized === 'return_done' || normalized === 'done' || normalized === 'da_tra') {
return 'returned';
}
if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') {
return 'rejected';
}
@@ -1787,6 +1790,7 @@ async function createTables() {
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL DEFAULT 1,
ReturnedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
RequestNote NVARCHAR(500) NULL,
@@ -1803,6 +1807,20 @@ async function createTables() {
)
END`,
// Asset Borrow/Return Links Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequestLinks')
BEGIN
CREATE TABLE AssetBorrowRequestLinks (
LinkId INT PRIMARY KEY IDENTITY(1,1),
BorrowId INT NOT NULL,
ReturnId INT NOT NULL,
Quantity INT NOT NULL DEFAULT 1,
CreatedDate DATETIME NOT NULL DEFAULT GETDATE(),
FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION,
FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION
)
END`,
// Asset Export History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN
@@ -1878,6 +1896,9 @@ async function createTables() {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_BorrowDate') CREATE INDEX IX_AssetBorrowRequests_BorrowDate ON AssetBorrowRequests(BorrowDate DESC);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestStatus') CREATE INDEX IX_AssetBorrowRequests_RequestStatus ON AssetBorrowRequests(RequestStatus);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestType') CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_BorrowId') CREATE INDEX IX_AssetBorrowRequestLinks_BorrowId ON AssetBorrowRequestLinks(BorrowId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_ReturnId') CREATE INDEX IX_AssetBorrowRequestLinks_ReturnId ON AssetBorrowRequestLinks(ReturnId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetBorrowRequestLinks_BorrowReturn') CREATE UNIQUE INDEX UX_AssetBorrowRequestLinks_BorrowReturn ON AssetBorrowRequestLinks(BorrowId, ReturnId);`);
} catch (err) {
console.error('AssetBorrowRequests index creation error:', err.message);
}
@@ -1913,6 +1934,7 @@ async function createTables() {
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','BorrowDate') IS NULL ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE));`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestType') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow');`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestStatus') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved');`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ReturnedQuantity') IS NULL ALTER TABLE AssetBorrowRequests ADD ReturnedQuantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequests_ReturnedQuantity DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestNote') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RejectReason') IS NULL ALTER TABLE AssetBorrowRequests ADD RejectReason NVARCHAR(1000) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedBy') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedBy INT NULL;`);
@@ -1929,6 +1951,20 @@ 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 NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequestLinks')
BEGIN
CREATE TABLE AssetBorrowRequestLinks (
LinkId INT PRIMARY KEY IDENTITY(1,1),
BorrowId INT NOT NULL,
ReturnId INT NOT NULL,
Quantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_Quantity DEFAULT(1),
CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_CreatedDate DEFAULT(GETDATE()),
FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION,
FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION
);
END
`);
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);`);
@@ -1959,6 +1995,68 @@ async function createTables() {
`);
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 AssetBorrowRequests SET ReturnedQuantity = 0 WHERE ReturnedQuantity IS NULL OR ReturnedQuantity < 0;`);
await pool.request().query(`
IF OBJECT_ID('dbo.AssetBorrowRequestLinks', 'U') IS NOT NULL
BEGIN
INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity)
SELECT matchedBorrow.BorrowId,
ret.BorrowId,
CASE
WHEN ISNULL(ret.BorrowQuantity, 0) <= 0 THEN 1
WHEN ISNULL(ret.BorrowQuantity, 0) > ISNULL(matchedBorrow.BorrowQuantity, 0) THEN ISNULL(matchedBorrow.BorrowQuantity, 0)
ELSE ISNULL(ret.BorrowQuantity, 0)
END
FROM AssetBorrowRequests ret
CROSS APPLY (
SELECT TOP 1 b.BorrowId, b.BorrowQuantity
FROM AssetBorrowRequests b
WHERE LOWER(LTRIM(RTRIM(ISNULL(b.RequestType, '')))) = 'borrow'
AND LOWER(LTRIM(RTRIM(ISNULL(b.RequestStatus, '')))) IN ('approved', 'returned')
AND b.AssetId = ret.AssetId
AND (
(ret.CreatedBy IS NOT NULL AND b.CreatedBy = ret.CreatedBy)
OR LOWER(LTRIM(RTRIM(ISNULL(b.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(ret.BorrowerName, ''))))
)
AND b.BorrowDate <= ret.BorrowDate
ORDER BY b.BorrowDate DESC, b.CreatedDate DESC, b.BorrowId DESC
) matchedBorrow
WHERE LOWER(LTRIM(RTRIM(ISNULL(ret.RequestType, '')))) = 'return'
AND LOWER(LTRIM(RTRIM(ISNULL(ret.RequestStatus, '')))) IN ('pending', 'approved')
AND NOT EXISTS (
SELECT 1
FROM AssetBorrowRequestLinks existed
WHERE existed.ReturnId = ret.BorrowId
)
AND NOT EXISTS (
SELECT 1
FROM AssetBorrowRequestLinks duplicateLink
WHERE duplicateLink.ReturnId = ret.BorrowId
AND duplicateLink.BorrowId = matchedBorrow.BorrowId
);
UPDATE borrowRows
SET ReturnedQuantity = CASE
WHEN summary.ReturnedQuantity > ISNULL(borrowRows.BorrowQuantity, 0) THEN ISNULL(borrowRows.BorrowQuantity, 0)
ELSE summary.ReturnedQuantity
END,
RequestStatus = CASE
WHEN summary.ReturnedQuantity >= ISNULL(borrowRows.BorrowQuantity, 0)
THEN 'returned'
ELSE borrowRows.RequestStatus
END,
UpdatedDate = GETDATE()
FROM AssetBorrowRequests borrowRows
INNER JOIN (
SELECT links.BorrowId, SUM(ISNULL(links.Quantity, 0)) AS ReturnedQuantity
FROM AssetBorrowRequestLinks links
INNER JOIN AssetBorrowRequests returns ON returns.BorrowId = links.ReturnId
WHERE LOWER(LTRIM(RTRIM(ISNULL(returns.RequestStatus, '')))) = 'approved'
GROUP BY links.BorrowId
) summary ON summary.BorrowId = borrowRows.BorrowId
WHERE LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestType, '')))) = 'borrow';
END
`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
await pool.request().query(`UPDATE AssetInventory SET UsedQuantity = CASE WHEN UsedQuantity < 0 THEN 0 ELSE ISNULL(UsedQuantity, 0) END;`);
@@ -3632,6 +3730,17 @@ app.get('/api/asset-borrows', async (req, res) => {
br.RequestStatus,
br.BorrowerName,
br.BorrowQuantity,
ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity,
CASE
WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow'
THEN CASE
WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0)
END
ELSE 0
END AS RemainingQuantity,
ISNULL(returnSummary.ReturnCount, 0) AS RelatedReturnCount,
ISNULL(borrowSummary.BorrowCount, 0) AS RelatedBorrowCount,
COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
br.BorrowDate,
br.RequestNote,
@@ -3643,6 +3752,36 @@ app.get('/api/asset-borrows', async (req, res) => {
br.CreatedDate
FROM AssetBorrowRequests br
LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId
OUTER APPLY (
SELECT COUNT(1) AS ReturnCount
FROM AssetBorrowRequests rr
WHERE LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow'
AND LOWER(LTRIM(RTRIM(ISNULL(rr.RequestType, '')))) = 'return'
AND LOWER(LTRIM(RTRIM(ISNULL(rr.RequestStatus, '')))) IN ('pending', 'approved')
AND (
EXISTS (
SELECT 1
FROM AssetBorrowRequestLinks l
WHERE l.BorrowId = br.BorrowId
AND l.ReturnId = rr.BorrowId
)
OR (
rr.AssetId = br.AssetId
AND rr.BorrowDate >= br.BorrowDate
AND (
(rr.CreatedBy IS NOT NULL AND br.CreatedBy IS NOT NULL AND rr.CreatedBy = br.CreatedBy)
OR LOWER(LTRIM(RTRIM(ISNULL(rr.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(br.BorrowerName, ''))))
)
)
)
) returnSummary
OUTER APPLY (
SELECT COUNT(1) AS BorrowCount
FROM AssetBorrowRequestLinks l
INNER JOIN AssetBorrowRequests bb ON bb.BorrowId = l.BorrowId
WHERE l.ReturnId = br.BorrowId
AND LOWER(LTRIM(RTRIM(ISNULL(bb.RequestType, '')))) = 'borrow'
) borrowSummary
${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'}
ORDER BY br.CreatedDate DESC, br.BorrowId DESC
`);
@@ -3653,7 +3792,182 @@ app.get('/api/asset-borrows', async (req, res) => {
}
});
app.get('/api/asset-borrows/:id/history', async (req, res) => {
try {
const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
const requesterId = getUserIdFromRequest(req);
const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset';
const borrowId = Number(req.params.id);
if (!Number.isInteger(borrowId) || borrowId <= 0) {
return res.status(400).json({ success: false, message: 'Ma don khong hop le' });
}
if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) {
return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' });
}
const targetRequest = pool.request()
.input('borrowId', sql.Int, borrowId)
.input('requesterId', sql.Int, requesterId || -1);
const targetResult = await targetRequest.query(`
SELECT TOP 1
br.BorrowId,
br.AssetId,
ai.AssetCode,
ai.AssetName,
br.RequestType,
br.RequestStatus,
br.BorrowerName,
br.BorrowQuantity,
ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity,
CASE
WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow'
THEN CASE
WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0)
END
ELSE 0
END AS RemainingQuantity,
COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
br.BorrowDate,
br.RequestNote,
br.RejectReason,
br.CreatedBy,
br.ProcessedBy,
br.ProcessedByName,
br.ProcessedDate,
br.CreatedDate
FROM AssetBorrowRequests br
LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId
WHERE br.BorrowId = @borrowId
${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'}
`);
const target = targetResult.recordset?.[0];
if (!target) {
return res.status(404).json({ success: false, message: 'Khong tim thay don' });
}
const targetType = normalizeAssetRequestType(target.RequestType);
const relatedRequest = pool.request()
.input('borrowId', sql.Int, borrowId)
.input('assetId', sql.Int, Number(target.AssetId) || -1)
.input('borrowerName', sql.NVarChar, String(target.BorrowerName || '').trim())
.input('createdBy', sql.Int, Number.isInteger(Number(target.CreatedBy)) ? Number(target.CreatedBy) : null)
.input('borrowDate', sql.Date, target.BorrowDate || null)
.input('targetType', sql.NVarChar, targetType)
.input('requesterId', sql.Int, requesterId || -1);
const relatedResult = await relatedRequest.query(`
;WITH linkedIds AS (
SELECT l.BorrowId AS RelatedId
FROM AssetBorrowRequestLinks l
WHERE l.ReturnId = @borrowId
UNION
SELECT l.ReturnId AS RelatedId
FROM AssetBorrowRequestLinks l
WHERE l.BorrowId = @borrowId
)
SELECT DISTINCT
br.BorrowId,
br.AssetId,
ai.AssetCode,
ai.AssetName,
br.RequestType,
br.RequestStatus,
br.BorrowerName,
br.BorrowQuantity,
ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity,
CASE
WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow'
THEN CASE
WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0)
END
ELSE 0
END AS RemainingQuantity,
COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
br.BorrowDate,
br.RequestNote,
br.RejectReason,
br.CreatedBy,
br.ProcessedBy,
br.ProcessedByName,
br.ProcessedDate,
br.CreatedDate
FROM AssetBorrowRequests br
LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId
WHERE (
br.BorrowId = @borrowId
OR br.BorrowId IN (SELECT RelatedId FROM linkedIds)
OR (
br.AssetId = @assetId
AND LOWER(LTRIM(RTRIM(ISNULL(br.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, ''))))
AND (
@createdBy IS NULL
OR br.CreatedBy = @createdBy
OR br.CreatedBy IS NULL
)
AND (
(@targetType = 'borrow'
AND LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'return'
AND br.BorrowDate >= @borrowDate)
OR
(@targetType = 'return'
AND LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow'
AND br.BorrowDate <= @borrowDate)
)
)
)
${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'}
ORDER BY br.BorrowDate ASC, br.CreatedDate ASC, br.BorrowId ASC
`);
const linkResult = await pool.request()
.input('borrowId', sql.Int, borrowId)
.query(`
SELECT
l.LinkId,
l.BorrowId,
l.ReturnId,
l.Quantity,
l.CreatedDate
FROM AssetBorrowRequestLinks l
WHERE l.BorrowId = @borrowId
OR l.ReturnId = @borrowId
OR l.BorrowId IN (
SELECT linked.BorrowId
FROM AssetBorrowRequestLinks linked
WHERE linked.ReturnId = @borrowId
)
OR l.ReturnId IN (
SELECT linked.ReturnId
FROM AssetBorrowRequestLinks linked
WHERE linked.BorrowId = @borrowId
)
ORDER BY l.CreatedDate ASC, l.LinkId ASC
`);
const relatedRows = Array.isArray(relatedResult.recordset) ? relatedResult.recordset : [];
res.json({
success: true,
data: {
request: target,
borrowRequests: relatedRows.filter(item => normalizeAssetRequestType(item.RequestType) === 'borrow'),
returnRequests: relatedRows.filter(item => normalizeAssetRequestType(item.RequestType) === 'return'),
links: Array.isArray(linkResult.recordset) ? linkResult.recordset : []
}
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-borrows', async (req, res) => {
let transaction;
try {
const createdBy = getUserIdFromRequest(req);
const actorName = await getUserDisplayNameById(createdBy);
@@ -3738,7 +4052,60 @@ app.post('/api/asset-borrows', async (req, res) => {
}
}
const insertResult = await pool.request()
transaction = new sql.Transaction(pool);
await transaction.begin();
let returnLinkRows = [];
if (requestType === 'return') {
const createdByValue = Number.isInteger(Number(createdBy)) ? Number(createdBy) : null;
const linkableResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('borrowerName', sql.NVarChar, borrowerName)
.input('createdBy', sql.Int, createdByValue)
.query(`
SELECT
borrowRows.BorrowId,
borrowRows.BorrowQuantity,
ISNULL(borrowRows.ReturnedQuantity, 0) AS ReturnedQuantity,
ISNULL(activeReturns.ActiveReturnQuantity, 0) AS ActiveReturnQuantity
FROM AssetBorrowRequests borrowRows WITH (UPDLOCK, HOLDLOCK)
OUTER APPLY (
SELECT SUM(ISNULL(links.Quantity, 0)) AS ActiveReturnQuantity
FROM AssetBorrowRequestLinks links
INNER JOIN AssetBorrowRequests returnRows ON returnRows.BorrowId = links.ReturnId
WHERE links.BorrowId = borrowRows.BorrowId
AND LOWER(LTRIM(RTRIM(ISNULL(returnRows.RequestStatus, '')))) IN ('pending', 'approved')
) activeReturns
WHERE borrowRows.AssetId = @assetId
AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestType, '')))) = 'borrow'
AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestStatus, '')))) IN ('approved', 'returned')
AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, ''))))
AND (
@createdBy IS NULL
OR borrowRows.CreatedBy = @createdBy
OR borrowRows.CreatedBy IS NULL
)
ORDER BY borrowRows.BorrowDate ASC, borrowRows.CreatedDate ASC, borrowRows.BorrowId ASC
`);
returnLinkRows = Array.isArray(linkableResult.recordset) ? linkableResult.recordset : [];
const availableReturnQuantity = returnLinkRows.reduce((sum, row) => {
const originalQuantity = parseNonNegativeInteger(row.BorrowQuantity, 0);
const returnedQuantity = parseNonNegativeInteger(row.ReturnedQuantity, 0);
const activeReturnQuantity = parseNonNegativeInteger(row.ActiveReturnQuantity, 0);
return sum + Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0);
}, 0);
if (borrowQuantity > availableReturnQuantity) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `So luong tra (${borrowQuantity}) vuot qua so luong con co the tao don tra (${availableReturnQuantity}). Co the da co don tra dang cho duyet.`
});
}
}
const insertResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('requestType', sql.NVarChar, requestType)
.input('requestStatus', sql.NVarChar, 'pending')
@@ -3773,16 +4140,58 @@ app.post('/api/asset-borrows', async (req, res) => {
SELECT SCOPE_IDENTITY() AS BorrowId;
`);
const createdRequestId = Number(insertResult.recordset?.[0]?.BorrowId) || null;
if (requestType === 'return' && createdRequestId) {
let remainingToLink = borrowQuantity;
for (const row of returnLinkRows) {
if (remainingToLink <= 0) {
break;
}
const borrowRequestId = Number(row.BorrowId);
const originalQuantity = parseNonNegativeInteger(row.BorrowQuantity, 0);
const returnedQuantity = parseNonNegativeInteger(row.ReturnedQuantity, 0);
const activeReturnQuantity = parseNonNegativeInteger(row.ActiveReturnQuantity, 0);
const availableQuantity = Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0);
const linkedQuantity = Math.min(availableQuantity, remainingToLink);
if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) {
continue;
}
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowRequestId)
.input('returnId', sql.Int, createdRequestId)
.input('quantity', sql.Int, linkedQuantity)
.query(`
INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity)
VALUES (@borrowId, @returnId, @quantity);
`);
remainingToLink -= linkedQuantity;
}
}
await transaction.commit();
res.json({
success: true,
message: requestType === 'return'
? 'Tao don tra tai san thanh cong. Don dang cho xu ly.'
: 'Tao don muon tai san thanh cong. Don dang cho xu ly.',
data: {
borrowId: insertResult.recordset?.[0]?.BorrowId || null
borrowId: createdRequestId
}
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
}
res.status(500).json({ success: false, message: err.message });
}
});
@@ -3827,6 +4236,7 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
br.BorrowQuantity,
br.BorrowDate,
br.Unit,
br.CreatedBy,
ai.AssetCode,
ai.AssetName,
ai.Quantity,
@@ -3990,6 +4400,133 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
const existingReturnLinksResult = await new sql.Request(transaction)
.input('returnId', sql.Int, borrowId)
.query(`
SELECT
links.BorrowId,
links.Quantity,
borrowRows.BorrowQuantity,
ISNULL(borrowRows.ReturnedQuantity, 0) AS ReturnedQuantity
FROM AssetBorrowRequestLinks links
INNER JOIN AssetBorrowRequests borrowRows WITH (UPDLOCK, HOLDLOCK)
ON borrowRows.BorrowId = links.BorrowId
WHERE links.ReturnId = @returnId
ORDER BY borrowRows.BorrowDate ASC, borrowRows.CreatedDate ASC, borrowRows.BorrowId ASC
`);
const existingReturnLinks = Array.isArray(existingReturnLinksResult.recordset)
? existingReturnLinksResult.recordset
: [];
if (existingReturnLinks.length) {
for (const linkRow of existingReturnLinks) {
const borrowRequestId = Number(linkRow.BorrowId);
const linkedQuantity = parseNonNegativeInteger(linkRow.Quantity, 0);
if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) {
continue;
}
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowRequestId)
.input('quantity', sql.Int, linkedQuantity)
.query(`
UPDATE AssetBorrowRequests
SET ReturnedQuantity = CASE
WHEN ISNULL(ReturnedQuantity, 0) + @quantity > ISNULL(BorrowQuantity, 0)
THEN ISNULL(BorrowQuantity, 0)
ELSE ISNULL(ReturnedQuantity, 0) + @quantity
END,
RequestStatus = CASE
WHEN ISNULL(ReturnedQuantity, 0) + @quantity >= ISNULL(BorrowQuantity, 0)
THEN 'returned'
ELSE RequestStatus
END,
UpdatedDate = GETDATE()
WHERE BorrowId = @borrowId
`);
}
} else {
let remainingToLink = requestQuantity;
const createdByValue = Number.isInteger(Number(targetRequest.CreatedBy))
? Number(targetRequest.CreatedBy)
: null;
const borrowRowsResult = await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrowerName', sql.NVarChar, borrowerName)
.input('createdBy', sql.Int, createdByValue)
.query(`
SELECT
BorrowId,
BorrowQuantity,
ISNULL(ReturnedQuantity, 0) AS ReturnedQuantity
FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK)
WHERE AssetId = @assetId
AND LOWER(LTRIM(RTRIM(ISNULL(RequestType, '')))) = 'borrow'
AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) IN ('approved', 'returned')
AND ISNULL(BorrowQuantity, 0) > ISNULL(ReturnedQuantity, 0)
AND LOWER(LTRIM(RTRIM(ISNULL(BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, ''))))
AND (
@createdBy IS NULL
OR CreatedBy = @createdBy
OR CreatedBy IS NULL
)
ORDER BY BorrowDate ASC, CreatedDate ASC, BorrowId ASC
`);
for (const borrowRow of (borrowRowsResult.recordset || [])) {
if (remainingToLink <= 0) {
break;
}
const borrowRequestId = Number(borrowRow.BorrowId);
const originalQuantity = parseNonNegativeInteger(borrowRow.BorrowQuantity, 0);
const alreadyReturned = parseNonNegativeInteger(borrowRow.ReturnedQuantity, 0);
const availableToReturn = Math.max(originalQuantity - alreadyReturned, 0);
const linkedQuantity = Math.min(availableToReturn, remainingToLink);
if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) {
continue;
}
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowRequestId)
.input('returnId', sql.Int, borrowId)
.input('quantity', sql.Int, linkedQuantity)
.query(`
IF NOT EXISTS (
SELECT 1
FROM AssetBorrowRequestLinks
WHERE BorrowId = @borrowId
AND ReturnId = @returnId
)
INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity)
VALUES (@borrowId, @returnId, @quantity);
`);
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowRequestId)
.input('quantity', sql.Int, linkedQuantity)
.query(`
UPDATE AssetBorrowRequests
SET ReturnedQuantity = CASE
WHEN ISNULL(ReturnedQuantity, 0) + @quantity > ISNULL(BorrowQuantity, 0)
THEN ISNULL(BorrowQuantity, 0)
ELSE ISNULL(ReturnedQuantity, 0) + @quantity
END,
RequestStatus = CASE
WHEN ISNULL(ReturnedQuantity, 0) + @quantity >= ISNULL(BorrowQuantity, 0)
THEN 'returned'
ELSE RequestStatus
END,
UpdatedDate = GETDATE()
WHERE BorrowId = @borrowId
`);
remainingToLink -= linkedQuantity;
}
}
}
}
@@ -4029,6 +4566,8 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
});
app.delete('/api/asset-borrows/:id', async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
const requesterId = getUserIdFromRequest(req);
@@ -4043,35 +4582,26 @@ app.delete('/api/asset-borrows/:id', async (req, res) => {
return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' });
}
const deleteResult = await pool.request()
await transaction.begin();
const targetResult = await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowId)
.input('requesterId', sql.Int, requesterId || -1)
.query(`
DELETE FROM AssetBorrowRequests
OUTPUT DELETED.BorrowId
SELECT TOP 1 BorrowId, RequestStatus, CreatedBy
FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK)
WHERE BorrowId = @borrowId
AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending'
${canManageRequests ? '' : 'AND CreatedBy = @requesterId'}
`);
if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) {
return res.json({ success: true, message: 'Da huy don cho' });
}
const existed = await pool.request()
.input('borrowId', sql.Int, borrowId)
.query(`
SELECT TOP 1 BorrowId, RequestStatus, CreatedBy
FROM AssetBorrowRequests
WHERE BorrowId = @borrowId
`);
const row = existed.recordset?.[0];
const row = targetResult.recordset?.[0];
if (!row) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' });
}
if (!canManageRequests && Number(row.CreatedBy) !== requesterId) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'Ban chi duoc huy don do chinh minh tao'
@@ -4080,17 +4610,37 @@ app.delete('/api/asset-borrows/:id', async (req, res) => {
const currentStatus = normalizeAssetRequestStatus(row.RequestStatus);
if (currentStatus !== 'pending') {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Chi duoc huy don o trang thai cho xu ly'
});
}
return res.status(400).json({
success: false,
message: 'Khong the huy don vao luc nay'
});
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowId)
.query(`
DELETE FROM AssetBorrowRequestLinks
WHERE BorrowId = @borrowId
OR ReturnId = @borrowId
`);
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowId)
.query(`
DELETE FROM AssetBorrowRequests
WHERE BorrowId = @borrowId
`);
await transaction.commit();
return res.json({ success: true, message: 'Da huy don cho' });
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
return res.status(500).json({ success: false, message: err.message });
}
});
@@ -4593,13 +5143,45 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
});
app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await pool.request()
.input('assetId', sql.Int, req.params.id)
.query('DELETE FROM AssetInventory WHERE AssetId = @assetId');
const assetId = Number(req.params.id);
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Asset id is invalid' });
}
await transaction.begin();
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.query(`
DELETE links
FROM AssetBorrowRequestLinks links
INNER JOIN AssetBorrowRequests requests
ON requests.BorrowId = links.BorrowId
OR requests.BorrowId = links.ReturnId
WHERE requests.AssetId = @assetId
`);
const deleteResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.query('DELETE FROM AssetInventory OUTPUT DELETED.AssetId WHERE AssetId = @assetId');
if (!deleteResult.recordset?.length) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Asset not found' });
}
await transaction.commit();
res.json({ success: true, message: 'Asset deleted' });
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
res.status(500).json({ success: false, message: err.message });
}
});