fix mượn trả
This commit is contained in:
@@ -4196,6 +4196,167 @@ app.post('/api/asset-borrows', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/asset-borrows/:id/return', 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);
|
||||||
|
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 muon khong hop le' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.begin();
|
||||||
|
|
||||||
|
const targetResult = await new sql.Request(transaction)
|
||||||
|
.input('borrowId', sql.Int, borrowId)
|
||||||
|
.input('requesterId', sql.Int, requesterId || -1)
|
||||||
|
.query(`
|
||||||
|
SELECT TOP 1
|
||||||
|
br.BorrowId,
|
||||||
|
br.AssetId,
|
||||||
|
br.RequestType,
|
||||||
|
br.RequestStatus,
|
||||||
|
br.BorrowerName,
|
||||||
|
br.BorrowQuantity,
|
||||||
|
ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity,
|
||||||
|
ISNULL(activeReturns.ActiveReturnQuantity, 0) AS ActiveReturnQuantity,
|
||||||
|
COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
|
||||||
|
br.CreatedBy,
|
||||||
|
ai.Borrower
|
||||||
|
FROM AssetBorrowRequests br WITH (UPDLOCK, HOLDLOCK)
|
||||||
|
INNER JOIN AssetInventory ai WITH (UPDLOCK, HOLDLOCK) ON ai.AssetId = br.AssetId
|
||||||
|
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 = br.BorrowId
|
||||||
|
AND LOWER(LTRIM(RTRIM(ISNULL(returnRows.RequestStatus, '')))) IN ('pending', 'approved')
|
||||||
|
) activeReturns
|
||||||
|
WHERE br.BorrowId = @borrowId
|
||||||
|
${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const targetRequest = targetResult.recordset?.[0];
|
||||||
|
if (!targetRequest) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(404).json({ success: false, message: 'Khong tim thay don muon can tra' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeAssetRequestType(targetRequest.RequestType) !== 'borrow') {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ success: false, message: 'Chi co the tao don tra tu don muon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeAssetRequestStatus(targetRequest.RequestStatus) !== 'approved') {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ success: false, message: 'Chi co the tra tai san khi don dang o trang thai dang muon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0);
|
||||||
|
const returnedQuantity = parseNonNegativeInteger(targetRequest.ReturnedQuantity, 0);
|
||||||
|
const activeReturnQuantity = parseNonNegativeInteger(targetRequest.ActiveReturnQuantity, 0);
|
||||||
|
const availableReturnQuantity = Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0);
|
||||||
|
|
||||||
|
if (availableReturnQuantity <= 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ success: false, message: 'Don muon nay da co don tra hoac da tra het' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowerName = String(targetRequest.BorrowerName || '').trim();
|
||||||
|
const borrowedEntry = parseBorrowerEntries(targetRequest.Borrower)
|
||||||
|
.find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase());
|
||||||
|
const currentBorrowedQuantity = parseNonNegativeInteger(borrowedEntry?.quantity, 0);
|
||||||
|
const returnQuantity = availableReturnQuantity;
|
||||||
|
|
||||||
|
if (currentBorrowedQuantity < returnQuantity) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ success: false, message: 'Khong con so luong dang muon de tao don tra' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalCreatedBy = Number(targetRequest.CreatedBy);
|
||||||
|
const createdBy = Number.isInteger(originalCreatedBy) && originalCreatedBy > 0
|
||||||
|
? originalCreatedBy
|
||||||
|
: requesterId;
|
||||||
|
|
||||||
|
const insertResult = await new sql.Request(transaction)
|
||||||
|
.input('assetId', sql.Int, targetRequest.AssetId)
|
||||||
|
.input('requestType', sql.NVarChar, 'return')
|
||||||
|
.input('requestStatus', sql.NVarChar, 'pending')
|
||||||
|
.input('borrowerName', sql.NVarChar, borrowerName)
|
||||||
|
.input('borrowQuantity', sql.Int, returnQuantity)
|
||||||
|
.input('unit', sql.NVarChar, String(targetRequest.Unit || '').trim() || null)
|
||||||
|
.input('borrowDate', sql.Date, new Date())
|
||||||
|
.input('requestNote', sql.NVarChar, `Tu dong tao tu don muon #${borrowId}`)
|
||||||
|
.input('createdBy', sql.Int, createdBy || null)
|
||||||
|
.query(`
|
||||||
|
INSERT INTO AssetBorrowRequests (
|
||||||
|
AssetId,
|
||||||
|
RequestType,
|
||||||
|
RequestStatus,
|
||||||
|
BorrowerName,
|
||||||
|
BorrowQuantity,
|
||||||
|
Unit,
|
||||||
|
BorrowDate,
|
||||||
|
RequestNote,
|
||||||
|
CreatedBy
|
||||||
|
) VALUES (
|
||||||
|
@assetId,
|
||||||
|
@requestType,
|
||||||
|
@requestStatus,
|
||||||
|
@borrowerName,
|
||||||
|
@borrowQuantity,
|
||||||
|
@unit,
|
||||||
|
@borrowDate,
|
||||||
|
@requestNote,
|
||||||
|
@createdBy
|
||||||
|
);
|
||||||
|
SELECT SCOPE_IDENTITY() AS BorrowId;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const returnRequestId = Number(insertResult.recordset?.[0]?.BorrowId) || null;
|
||||||
|
if (!returnRequestId) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(500).json({ success: false, message: 'Khong tao duoc don tra tai san' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await new sql.Request(transaction)
|
||||||
|
.input('borrowId', sql.Int, borrowId)
|
||||||
|
.input('returnId', sql.Int, returnRequestId)
|
||||||
|
.input('quantity', sql.Int, returnQuantity)
|
||||||
|
.query(`
|
||||||
|
INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity)
|
||||||
|
VALUES (@borrowId, @returnId, @quantity);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Da tao don tra tai san. Don dang cho xu ly.',
|
||||||
|
data: {
|
||||||
|
borrowId: returnRequestId,
|
||||||
|
sourceBorrowId: borrowId,
|
||||||
|
quantity: returnQuantity
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
|
app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
|
||||||
const transaction = new sql.Transaction(pool);
|
const transaction = new sql.Transaction(pool);
|
||||||
|
|
||||||
|
|||||||
@@ -3018,6 +3018,7 @@ class AccountManager {
|
|||||||
const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0);
|
const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0);
|
||||||
const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0);
|
const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0);
|
||||||
const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0));
|
const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0));
|
||||||
|
const canCreateReturn = this.canCreateAssetReturnRequestFromBorrow(item);
|
||||||
const returnProgressHtml = requestType === 'borrow' && returnedQuantity > 0 && statusMeta.value !== 'returned'
|
const returnProgressHtml = requestType === 'borrow' && returnedQuantity > 0 && statusMeta.value !== 'returned'
|
||||||
? `<div class="mt-1 text-[11px] text-slate-500 whitespace-nowrap">Đã trả ${returnedQuantity}/${borrowQuantity}, còn ${remainingQuantity}</div>`
|
? `<div class="mt-1 text-[11px] text-slate-500 whitespace-nowrap">Đã trả ${returnedQuantity}/${borrowQuantity}, còn ${remainingQuantity}</div>`
|
||||||
: '';
|
: '';
|
||||||
@@ -3031,8 +3032,17 @@ class AccountManager {
|
|||||||
<span class="material-symbols-outlined text-base">info</span>
|
<span class="material-symbols-outlined text-base">info</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
const returnActionHtml = canCreateReturn
|
||||||
|
? `<button class="asset-borrow-return-btn inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold whitespace-nowrap" data-request-id="${requestId}" title="Tạo đơn trả từ đơn mượn này">
|
||||||
|
<span class="material-symbols-outlined text-sm">assignment_return</span>
|
||||||
|
Trả tài sản
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
const cancelActionHtml = canCancel
|
const cancelActionHtml = canCancel
|
||||||
? `<button class="asset-borrow-cancel-btn px-3 py-1.5 rounded-md bg-red-600 hover:bg-red-700 text-white text-xs font-bold whitespace-nowrap" data-request-id="${requestId}">Hủy đơn</button>`
|
? `<button class="asset-borrow-cancel-btn px-3 py-1.5 rounded-md bg-red-600 hover:bg-red-700 text-white text-xs font-bold whitespace-nowrap" data-request-id="${requestId}">Hủy đơn</button>`
|
||||||
|
: '';
|
||||||
|
const actionHtml = returnActionHtml || cancelActionHtml
|
||||||
|
? `<div class="flex items-center gap-2">${returnActionHtml}${cancelActionHtml}</div>`
|
||||||
: `<span class="text-xs text-slate-400">-</span>`;
|
: `<span class="text-xs text-slate-400">-</span>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -3056,11 +3066,28 @@ class AccountManager {
|
|||||||
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(note || '-')}</td>
|
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(note || '-')}</td>
|
||||||
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(rejectReason || '-')}</td>
|
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(rejectReason || '-')}</td>
|
||||||
<td class="px-4 py-3 text-sm text-slate-600 text-center">${detailActionHtml}</td>
|
<td class="px-4 py-3 text-sm text-slate-600 text-center">${detailActionHtml}</td>
|
||||||
<td class="px-4 py-3 text-sm text-slate-600">${cancelActionHtml}</td>
|
<td class="px-4 py-3 text-sm text-slate-600">${actionHtml}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canCreateAssetReturnRequestFromBorrow(item) {
|
||||||
|
if (this.normalizeAssetRequestType(item?.RequestType) !== 'borrow') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.normalizeAssetRequestStatus(item?.RequestStatus) !== 'approved') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0);
|
||||||
|
const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0);
|
||||||
|
const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0));
|
||||||
|
const relatedReturnCount = this.parseNonNegativeInteger(item?.RelatedReturnCount, 0);
|
||||||
|
|
||||||
|
return borrowQuantity > 0 && remainingQuantity > 0 && relatedReturnCount <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
canCurrentUserCancelAssetRequest(item) {
|
canCurrentUserCancelAssetRequest(item) {
|
||||||
const status = this.normalizeAssetRequestStatus(item?.RequestStatus);
|
const status = this.normalizeAssetRequestStatus(item?.RequestStatus);
|
||||||
if (status !== 'pending') {
|
if (status !== 'pending') {
|
||||||
@@ -3577,6 +3604,15 @@ class AccountManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returnButton = event.target.closest('.asset-borrow-return-btn');
|
||||||
|
if (returnButton) {
|
||||||
|
const requestId = Number(returnButton.dataset.requestId);
|
||||||
|
if (Number.isFinite(requestId) && requestId > 0) {
|
||||||
|
this.createAssetReturnRequestFromBorrow(requestId, returnButton);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cancelButton = event.target.closest('.asset-borrow-cancel-btn');
|
const cancelButton = event.target.closest('.asset-borrow-cancel-btn');
|
||||||
if (!cancelButton) {
|
if (!cancelButton) {
|
||||||
return;
|
return;
|
||||||
@@ -3601,6 +3637,61 @@ class AccountManager {
|
|||||||
this.setupAssetBorrowPagerListeners();
|
this.setupAssetBorrowPagerListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAssetReturnRequestFromBorrow(requestId, sourceButton = null) {
|
||||||
|
const targetId = Number(requestId);
|
||||||
|
if (!Number.isFinite(targetId) || targetId <= 0) {
|
||||||
|
this.notifyWarning('Không xác định được đơn mượn cần trả.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceButton) {
|
||||||
|
sourceButton.disabled = true;
|
||||||
|
sourceButton.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBase}/asset-borrows/${targetId}/return`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getAuthHeaders(false)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
this.notifyFailure(data.message || 'Tạo đơn trả tài sản thất bại');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifySuccess('Đã tạo đơn trả tài sản. Đơn đang chờ xử lý.');
|
||||||
|
await this.fetchAssetBorrows();
|
||||||
|
await this.fetchAssets();
|
||||||
|
|
||||||
|
if (this.currentPage === 'asset-borrows') {
|
||||||
|
this.renderAssetBorrowsTableBody();
|
||||||
|
}
|
||||||
|
if (this.currentPage === 'assets') {
|
||||||
|
this.renderAssetsTableBody();
|
||||||
|
}
|
||||||
|
if (this.currentPage === 'my-borrowed-assets') {
|
||||||
|
this.renderMyBorrowedAssetsTableBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingModal = document.getElementById('assetPendingRequestsModal');
|
||||||
|
if (pendingModal?.classList.contains('open')) {
|
||||||
|
this.renderPendingAssetRequestsModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePendingAssetRequestsBadge();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
this.notifyFailure('Tạo đơn trả tài sản thất bại');
|
||||||
|
} finally {
|
||||||
|
if (sourceButton && document.body.contains(sourceButton)) {
|
||||||
|
sourceButton.disabled = false;
|
||||||
|
sourceButton.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAssetBorrowsUI() {
|
async refreshAssetBorrowsUI() {
|
||||||
await this.fetchAssetBorrows();
|
await this.fetchAssetBorrows();
|
||||||
if (this.currentPage === 'asset-borrows') {
|
if (this.currentPage === 'asset-borrows') {
|
||||||
|
|||||||
Reference in New Issue
Block a user