request
This commit is contained in:
@@ -323,19 +323,19 @@ function parseNullableDate(value) {
|
||||
function normalizeAssetStatus(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
|
||||
if (['in_use', 'in use', 'dang su dung', 'đang sử dụng', 'active'].includes(normalized)) {
|
||||
if (['in_use', 'in use', 'dang su dung', 'active'].includes(normalized)) {
|
||||
return 'in_use';
|
||||
}
|
||||
|
||||
if (['maintenance', 'bao tri', 'bảo trì'].includes(normalized)) {
|
||||
if (['maintenance', 'bao tri'].includes(normalized)) {
|
||||
return 'maintenance';
|
||||
}
|
||||
|
||||
if (['disposed', 'thanh ly', 'thanh lý', 'retired'].includes(normalized)) {
|
||||
if (['disposed', 'thanh ly', 'retired'].includes(normalized)) {
|
||||
return 'disposed';
|
||||
}
|
||||
|
||||
if (['in_stock', 'in stock', 'ton kho', 'tồn kho', 'warehouse'].includes(normalized)) {
|
||||
if (['in_stock', 'in stock', 'ton kho', 'warehouse'].includes(normalized)) {
|
||||
return 'in_stock';
|
||||
}
|
||||
|
||||
@@ -368,11 +368,155 @@ function normalizeAssetPayload(payload = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseBorrowerEntries(rawBorrower) {
|
||||
const source = String(rawBorrower || '').trim();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks = source
|
||||
.split(/[\n;]+/g)
|
||||
.map(item => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const merged = [];
|
||||
|
||||
chunks.forEach(chunk => {
|
||||
let name = chunk;
|
||||
let quantity = 1;
|
||||
|
||||
const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i);
|
||||
if (labeledMatch) {
|
||||
name = String(labeledMatch[1] || '').trim();
|
||||
quantity = parseNonNegativeInteger(labeledMatch[2], 1);
|
||||
} else {
|
||||
const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/);
|
||||
const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i);
|
||||
const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/);
|
||||
const fallbackMatch = colonMatch || xMatch || parenMatch;
|
||||
if (fallbackMatch) {
|
||||
name = String(fallbackMatch[1] || '').trim();
|
||||
quantity = parseNonNegativeInteger(fallbackMatch[2], 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name || quantity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
|
||||
if (existed) {
|
||||
existed.quantity += quantity;
|
||||
} else {
|
||||
merged.push({ name, quantity });
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatBorrowerEntries(entries = []) {
|
||||
if (!Array.isArray(entries) || !entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = entries
|
||||
.map(entry => ({
|
||||
name: String(entry?.name || '').trim(),
|
||||
quantity: parseNonNegativeInteger(entry?.quantity, 0)
|
||||
}))
|
||||
.filter(entry => entry.name && entry.quantity > 0);
|
||||
|
||||
if (!normalized.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.map(entry => `${entry.name} - so luong: ${entry.quantity}`).join('; ');
|
||||
}
|
||||
|
||||
function mergeBorrowerEntries(existingBorrower, borrowerName, borrowQuantity) {
|
||||
const merged = parseBorrowerEntries(existingBorrower);
|
||||
const name = String(borrowerName || '').trim();
|
||||
const quantity = parseNonNegativeInteger(borrowQuantity, 0);
|
||||
|
||||
if (!name || quantity <= 0) {
|
||||
return formatBorrowerEntries(merged);
|
||||
}
|
||||
|
||||
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
|
||||
if (existed) {
|
||||
existed.quantity += quantity;
|
||||
} else {
|
||||
merged.push({ name, quantity });
|
||||
}
|
||||
|
||||
return formatBorrowerEntries(merged);
|
||||
}
|
||||
|
||||
function decreaseBorrowerEntries(existingBorrower, borrowerName, returnQuantity) {
|
||||
const merged = parseBorrowerEntries(existingBorrower);
|
||||
const name = String(borrowerName || '').trim();
|
||||
const quantity = parseNonNegativeInteger(returnQuantity, 0);
|
||||
|
||||
if (!name || quantity <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid return payload',
|
||||
entries: merged
|
||||
};
|
||||
}
|
||||
|
||||
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
|
||||
if (!existed) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User has no borrowed quantity to return',
|
||||
entries: merged
|
||||
};
|
||||
}
|
||||
|
||||
if (existed.quantity < quantity) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Return quantity (${quantity}) exceeds borrowed quantity (${existed.quantity})`,
|
||||
entries: merged
|
||||
};
|
||||
}
|
||||
|
||||
existed.quantity -= quantity;
|
||||
const normalized = merged.filter(entry => parseNonNegativeInteger(entry?.quantity, 0) > 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
entries: normalized,
|
||||
summary: formatBorrowerEntries(normalized)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAssetRequestType(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'return' || normalized === 'tra' || normalized === 'return_asset') {
|
||||
return 'return';
|
||||
}
|
||||
return 'borrow';
|
||||
}
|
||||
|
||||
function normalizeAssetRequestStatus(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') {
|
||||
return 'approved';
|
||||
}
|
||||
if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') {
|
||||
return 'rejected';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function normalizeImportToken(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[đĐ]/g, 'd')
|
||||
.replace(/[\u0111\u0110]/g, 'd')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
@@ -710,7 +854,7 @@ function sanitizeAssetCodeToken(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[đĐ]/g, 'd')
|
||||
.replace(/[\u0111\u0110]/g, 'd')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
@@ -1173,7 +1317,7 @@ async function initializeDatabase() {
|
||||
try {
|
||||
pool = new sql.ConnectionPool(sqlConfig);
|
||||
await pool.connect();
|
||||
console.log('✓ Connected to SQL Server');
|
||||
console.log('[OK] Connected to SQL Server');
|
||||
|
||||
// Check and create database if not exists
|
||||
const masterConnection = new sql.ConnectionPool({
|
||||
@@ -1198,7 +1342,7 @@ async function initializeDatabase() {
|
||||
// Now create tables in AccManager
|
||||
await createTables();
|
||||
await migrateLegacyPasswords();
|
||||
console.log('✓ Database and tables created');
|
||||
console.log('[OK] Database and tables created');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Database connection failed:', err);
|
||||
@@ -1240,7 +1384,7 @@ async function migrateLegacyPasswords() {
|
||||
}
|
||||
|
||||
if (migratedCount > 0) {
|
||||
console.log(`✓ Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
|
||||
console.log(`[OK] Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Password migration error:', err.message);
|
||||
@@ -1343,6 +1487,32 @@ async function createTables() {
|
||||
UpdatedDate DATETIME DEFAULT GETDATE()
|
||||
)
|
||||
END`,
|
||||
|
||||
// Asset Borrow Requests Table
|
||||
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests')
|
||||
BEGIN
|
||||
CREATE TABLE AssetBorrowRequests (
|
||||
BorrowId INT PRIMARY KEY IDENTITY(1,1),
|
||||
AssetId INT NOT NULL,
|
||||
RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow',
|
||||
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
BorrowerName NVARCHAR(100) NOT NULL,
|
||||
BorrowQuantity INT NOT NULL DEFAULT 1,
|
||||
Unit NVARCHAR(50),
|
||||
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
|
||||
RequestNote NVARCHAR(500) NULL,
|
||||
RejectReason NVARCHAR(1000) NULL,
|
||||
CreatedBy INT NULL,
|
||||
ProcessedBy INT NULL,
|
||||
ProcessedByName NVARCHAR(100) NULL,
|
||||
ProcessedDate DATETIME NULL,
|
||||
CreatedDate DATETIME DEFAULT GETDATE(),
|
||||
UpdatedDate DATETIME DEFAULT GETDATE(),
|
||||
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
|
||||
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL,
|
||||
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
|
||||
)
|
||||
END`,
|
||||
|
||||
// AuditLog Table
|
||||
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
|
||||
@@ -1385,6 +1555,16 @@ async function createTables() {
|
||||
console.error('AssetDepartments index creation error:', err.message);
|
||||
}
|
||||
|
||||
// Ensure AssetBorrowRequests indexes exist
|
||||
try {
|
||||
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId);`);
|
||||
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);`);
|
||||
} catch (err) {
|
||||
console.error('AssetBorrowRequests index creation error:', err.message);
|
||||
}
|
||||
|
||||
// Ensure new columns exist on Applications for migrations
|
||||
try {
|
||||
await pool.request().query(`IF EXISTS (
|
||||
@@ -1402,6 +1582,28 @@ async function createTables() {
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Project') IS NULL ALTER TABLE AssetInventory ADD Project NVARCHAR(150) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Borrower') IS NULL ALTER TABLE AssetInventory ADD Borrower NVARCHAR(255) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportedBy') IS NULL ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','Unit') IS NULL ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL;`);
|
||||
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','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;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedByName') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedByName NVARCHAR(100) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedDate DATETIME NULL;`);
|
||||
await pool.request().query(`
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.foreign_keys
|
||||
WHERE name = 'FK_AssetBorrowRequests_ProcessedBy'
|
||||
)
|
||||
ALTER TABLE AssetBorrowRequests
|
||||
ADD CONSTRAINT FK_AssetBorrowRequests_ProcessedBy
|
||||
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(`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));`);
|
||||
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
|
||||
await pool.request().query(`
|
||||
@@ -1455,7 +1657,7 @@ async function createTables() {
|
||||
SET EmailVerified = 1,
|
||||
EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
|
||||
WHERE Username = @username`);
|
||||
console.log('✓ Admin user created: admin / admin');
|
||||
console.log('[OK] Admin user created: admin / admin');
|
||||
} catch (err) {
|
||||
console.error('Admin user error:', err.message);
|
||||
}
|
||||
@@ -1472,7 +1674,7 @@ async function createTables() {
|
||||
('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'),
|
||||
('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org')
|
||||
END`);
|
||||
console.log('✓ Sample applications created');
|
||||
console.log('[OK] Sample applications created');
|
||||
} catch (err) {
|
||||
console.error('Applications error:', err.message);
|
||||
}
|
||||
@@ -2499,7 +2701,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
|
||||
`);
|
||||
|
||||
if (existed.recordset.length > 0) {
|
||||
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
|
||||
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
|
||||
}
|
||||
|
||||
const inserted = await pool.request()
|
||||
@@ -2517,7 +2719,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
|
||||
});
|
||||
} catch (err) {
|
||||
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
|
||||
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
|
||||
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
|
||||
}
|
||||
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
@@ -2528,7 +2730,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
|
||||
try {
|
||||
const departmentId = Number(req.params.id);
|
||||
if (!Number.isInteger(departmentId) || departmentId <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' });
|
||||
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' });
|
||||
}
|
||||
|
||||
const departmentName = normalizeDepartmentName(req.body?.departmentName);
|
||||
@@ -2568,7 +2770,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
|
||||
`);
|
||||
|
||||
if (duplicated.recordset.length > 0) {
|
||||
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
|
||||
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
|
||||
}
|
||||
|
||||
const transaction = new sql.Transaction(pool);
|
||||
@@ -2607,7 +2809,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
|
||||
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
|
||||
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
|
||||
}
|
||||
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
@@ -2618,7 +2820,7 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) =
|
||||
try {
|
||||
const departmentId = Number(req.params.id);
|
||||
if (!Number.isInteger(departmentId) || departmentId <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' });
|
||||
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' });
|
||||
}
|
||||
|
||||
await syncAssetDepartmentsFromInventory();
|
||||
@@ -2675,6 +2877,401 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) =
|
||||
// API ROUTES - Asset Inventory
|
||||
// ==========================================
|
||||
|
||||
app.get('/api/asset-borrows', 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 request = pool.request();
|
||||
if (!canManageRequests) {
|
||||
request.input('requesterId', sql.Int, requesterId || -1);
|
||||
}
|
||||
|
||||
const result = await request.query(`
|
||||
SELECT
|
||||
br.BorrowId,
|
||||
br.AssetId,
|
||||
ai.AssetCode,
|
||||
ai.AssetName,
|
||||
br.RequestType,
|
||||
br.RequestStatus,
|
||||
br.BorrowerName,
|
||||
br.BorrowQuantity,
|
||||
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
|
||||
${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'}
|
||||
ORDER BY br.CreatedDate DESC, br.BorrowId DESC
|
||||
`);
|
||||
|
||||
res.json({ success: true, data: result.recordset });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/asset-borrows', async (req, res) => {
|
||||
try {
|
||||
const createdBy = getUserIdFromRequest(req);
|
||||
const actorName = await getUserDisplayNameById(createdBy);
|
||||
const assetId = Number(req.body?.assetId);
|
||||
const requestType = normalizeAssetRequestType(req.body?.requestType);
|
||||
const borrowQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
|
||||
const requestedBorrowDate = parseNullableDate(req.body?.borrowDate);
|
||||
const borrowDate = requestedBorrowDate || new Date();
|
||||
const borrowerName = String(actorName || req.body?.borrowerName || '').trim();
|
||||
const requestNote = String(req.body?.note || '').trim() || null;
|
||||
|
||||
if (!Number.isInteger(assetId) || assetId <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Tai san khong hop le' });
|
||||
}
|
||||
|
||||
if (!borrowerName) {
|
||||
return res.status(400).json({ success: false, message: 'Khong xac dinh duoc nguoi tao don' });
|
||||
}
|
||||
|
||||
if (borrowQuantity <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'So luong phai lon hon 0' });
|
||||
}
|
||||
|
||||
const assetResult = await pool.request()
|
||||
.input('assetId', sql.Int, assetId)
|
||||
.query(`
|
||||
SELECT TOP 1
|
||||
AssetId,
|
||||
AssetCode,
|
||||
AssetName,
|
||||
Quantity,
|
||||
ImportInPeriod,
|
||||
Borrower,
|
||||
Unit
|
||||
FROM AssetInventory
|
||||
WHERE AssetId = @assetId
|
||||
`);
|
||||
|
||||
const asset = assetResult.recordset?.[0];
|
||||
if (!asset) {
|
||||
return res.status(404).json({ success: false, message: 'Khong tim thay tai san' });
|
||||
}
|
||||
|
||||
const currentBorrowedEntries = parseBorrowerEntries(asset.Borrower);
|
||||
const currentBorrowed = currentBorrowedEntries.reduce((sum, entry) => (
|
||||
sum + parseNonNegativeInteger(entry?.quantity, 0)
|
||||
), 0);
|
||||
const endingBalance = Math.max(
|
||||
parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed,
|
||||
0
|
||||
);
|
||||
|
||||
const unit = String(req.body?.unit || '').trim() || String(asset.Unit || '').trim() || null;
|
||||
|
||||
if (requestType === 'borrow') {
|
||||
if (endingBalance <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky' });
|
||||
}
|
||||
|
||||
if (borrowQuantity > endingBalance) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `So luong muon (${borrowQuantity}) vuot qua ton cuoi ky (${endingBalance})`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const existed = currentBorrowedEntries.find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase());
|
||||
const borrowedQuantity = parseNonNegativeInteger(existed?.quantity, 0);
|
||||
|
||||
if (borrowedQuantity <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ban chua co du lieu muon tai san nay de tao don tra'
|
||||
});
|
||||
}
|
||||
|
||||
if (borrowQuantity > borrowedQuantity) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `So luong tra (${borrowQuantity}) vuot qua so luong dang muon (${borrowedQuantity})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const insertResult = await pool.request()
|
||||
.input('assetId', sql.Int, assetId)
|
||||
.input('requestType', sql.NVarChar, requestType)
|
||||
.input('requestStatus', sql.NVarChar, 'pending')
|
||||
.input('borrowerName', sql.NVarChar, borrowerName)
|
||||
.input('borrowQuantity', sql.Int, borrowQuantity)
|
||||
.input('unit', sql.NVarChar, unit)
|
||||
.input('borrowDate', sql.Date, borrowDate)
|
||||
.input('requestNote', sql.NVarChar, requestNote)
|
||||
.input('createdBy', sql.Int, createdBy)
|
||||
.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;
|
||||
`);
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
|
||||
const transaction = new sql.Transaction(pool);
|
||||
|
||||
try {
|
||||
const borrowId = Number(req.params.id);
|
||||
const action = normalizeAssetRequestStatus(req.body?.action);
|
||||
const rejectReason = String(req.body?.rejectReason || '').trim() || null;
|
||||
const processedBy = getUserIdFromRequest(req);
|
||||
const processorName = String(
|
||||
await getUserDisplayNameById(processedBy)
|
||||
|| req.headers['x-user-role']
|
||||
|| 'Asset/Admin'
|
||||
).trim();
|
||||
|
||||
if (!Number.isInteger(borrowId) || borrowId <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Ma don khong hop le' });
|
||||
}
|
||||
|
||||
if (!['approved', 'rejected'].includes(action)) {
|
||||
return res.status(400).json({ success: false, message: 'Hanh dong khong hop le' });
|
||||
}
|
||||
|
||||
if (action === 'rejected' && !rejectReason) {
|
||||
return res.status(400).json({ success: false, message: 'Vui long nhap ly do tu choi' });
|
||||
}
|
||||
|
||||
await transaction.begin();
|
||||
|
||||
const requestResult = await new sql.Request(transaction)
|
||||
.input('borrowId', sql.Int, borrowId)
|
||||
.query(`
|
||||
SELECT TOP 1
|
||||
br.BorrowId,
|
||||
br.AssetId,
|
||||
br.RequestType,
|
||||
br.RequestStatus,
|
||||
br.BorrowerName,
|
||||
br.BorrowQuantity,
|
||||
br.BorrowDate,
|
||||
br.Unit,
|
||||
ai.AssetCode,
|
||||
ai.AssetName,
|
||||
ai.Quantity,
|
||||
ai.ImportInPeriod,
|
||||
ai.Borrower,
|
||||
ai.Unit AS AssetUnit
|
||||
FROM AssetBorrowRequests br
|
||||
INNER JOIN AssetInventory ai ON ai.AssetId = br.AssetId
|
||||
WHERE br.BorrowId = @borrowId
|
||||
`);
|
||||
|
||||
const targetRequest = requestResult.recordset?.[0];
|
||||
if (!targetRequest) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Khong tim thay don can xu ly' });
|
||||
}
|
||||
|
||||
const currentStatus = normalizeAssetRequestStatus(targetRequest.RequestStatus);
|
||||
if (currentStatus !== 'pending') {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ success: false, message: 'Don nay da duoc xu ly truoc do' });
|
||||
}
|
||||
|
||||
if (action === 'approved') {
|
||||
const requestType = normalizeAssetRequestType(targetRequest.RequestType);
|
||||
const borrowerName = String(targetRequest.BorrowerName || '').trim();
|
||||
const requestQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0);
|
||||
|
||||
if (requestType === 'borrow') {
|
||||
const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => (
|
||||
sum + parseNonNegativeInteger(entry?.quantity, 0)
|
||||
), 0);
|
||||
const endingBalance = Math.max(
|
||||
parseNonNegativeInteger(targetRequest.Quantity, 0)
|
||||
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
|
||||
- currentBorrowed,
|
||||
0
|
||||
);
|
||||
|
||||
if (requestQuantity > endingBalance) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Khong du ton kho de duyet. Ton hien tai: ${endingBalance}`
|
||||
});
|
||||
}
|
||||
|
||||
const mergedBorrowerSummary = mergeBorrowerEntries(
|
||||
targetRequest.Borrower,
|
||||
borrowerName,
|
||||
requestQuantity
|
||||
);
|
||||
|
||||
if (mergedBorrowerSummary && mergedBorrowerSummary.length > 255) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Thong tin nguoi muon qua dai, vui long tu choi don va yeu cau nguoi dung dieu chinh.'
|
||||
});
|
||||
}
|
||||
|
||||
await new sql.Request(transaction)
|
||||
.input('assetId', sql.Int, targetRequest.AssetId)
|
||||
.input('borrower', sql.NVarChar, mergedBorrowerSummary)
|
||||
.input('exportedBy', sql.NVarChar, processorName || null)
|
||||
.query(`
|
||||
UPDATE AssetInventory
|
||||
SET Borrower = @borrower,
|
||||
ExportedBy = @exportedBy,
|
||||
UpdatedDate = GETDATE()
|
||||
WHERE AssetId = @assetId
|
||||
`);
|
||||
} else {
|
||||
const decreased = decreaseBorrowerEntries(
|
||||
targetRequest.Borrower,
|
||||
borrowerName,
|
||||
requestQuantity
|
||||
);
|
||||
|
||||
if (!decreased.success) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Không thể duyệt trả tài sản: số lượng trả không hợp lệ hoặc không còn người mượn. Bạn có thể xóa đơn chờ này.'
|
||||
});
|
||||
}
|
||||
|
||||
const borrowerSummary = decreased.summary || null;
|
||||
await new sql.Request(transaction)
|
||||
.input('assetId', sql.Int, targetRequest.AssetId)
|
||||
.input('borrower', sql.NVarChar, borrowerSummary)
|
||||
.input('exportedBy', sql.NVarChar, processorName || null)
|
||||
.query(`
|
||||
UPDATE AssetInventory
|
||||
SET Borrower = @borrower,
|
||||
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
|
||||
UpdatedDate = GETDATE()
|
||||
WHERE AssetId = @assetId
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await new sql.Request(transaction)
|
||||
.input('borrowId', sql.Int, borrowId)
|
||||
.input('requestStatus', sql.NVarChar, action)
|
||||
.input('rejectReason', sql.NVarChar, action === 'rejected' ? rejectReason : null)
|
||||
.input('processedBy', sql.Int, processedBy)
|
||||
.input('processedByName', sql.NVarChar, processorName || null)
|
||||
.query(`
|
||||
UPDATE AssetBorrowRequests
|
||||
SET RequestStatus = @requestStatus,
|
||||
RejectReason = @rejectReason,
|
||||
ProcessedBy = @processedBy,
|
||||
ProcessedByName = @processedByName,
|
||||
ProcessedDate = GETDATE(),
|
||||
UpdatedDate = GETDATE()
|
||||
WHERE BorrowId = @borrowId
|
||||
`);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: action === 'approved'
|
||||
? 'Da duyet don thanh cong'
|
||||
: 'Da tu choi don'
|
||||
});
|
||||
} 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.delete('/api/asset-borrows/:id', requireAssetOrAdmin, async (req, res) => {
|
||||
try {
|
||||
const borrowId = Number(req.params.id);
|
||||
if (!Number.isInteger(borrowId) || borrowId <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Mã đơn không hợp lệ' });
|
||||
}
|
||||
|
||||
const deleteResult = await pool.request()
|
||||
.input('borrowId', sql.Int, borrowId)
|
||||
.query(`
|
||||
DELETE FROM AssetBorrowRequests
|
||||
OUTPUT DELETED.BorrowId
|
||||
WHERE BorrowId = @borrowId
|
||||
AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending'
|
||||
`);
|
||||
|
||||
if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) {
|
||||
return res.json({ success: true, message: 'Đã xóa đơn chờ' });
|
||||
}
|
||||
|
||||
const existed = await pool.request()
|
||||
.input('borrowId', sql.Int, borrowId)
|
||||
.query(`
|
||||
SELECT TOP 1 BorrowId, RequestStatus
|
||||
FROM AssetBorrowRequests
|
||||
WHERE BorrowId = @borrowId
|
||||
`);
|
||||
|
||||
const row = existed.recordset?.[0];
|
||||
if (!row) {
|
||||
return res.status(404).json({ success: false, message: 'Không tìm thấy đơn cần xóa' });
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Chỉ được xóa đơn ở trạng thái chờ xử lý'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/assets', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.request().query(`
|
||||
@@ -2692,6 +3289,72 @@ app.get('/api/assets', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/assets/search', async (req, res) => {
|
||||
try {
|
||||
const rawKeyword = String(req.query.q || '').trim();
|
||||
const keywordLike = `%${rawKeyword}%`;
|
||||
const limit = Math.min(parsePositiveInteger(req.query.limit, 80), 200);
|
||||
const offset = parseNonNegativeInteger(req.query.offset, 0);
|
||||
|
||||
const result = await pool.request()
|
||||
.input('limit', sql.Int, limit)
|
||||
.input('offset', sql.Int, offset)
|
||||
.input('keyword', sql.NVarChar, rawKeyword)
|
||||
.input('keywordLike', sql.NVarChar, keywordLike)
|
||||
.query(`
|
||||
;WITH FilteredAssets AS (
|
||||
SELECT
|
||||
AssetId,
|
||||
AssetCode,
|
||||
AssetName,
|
||||
Unit,
|
||||
UpdatedDate,
|
||||
CASE WHEN @keyword <> '' AND AssetCode LIKE @keywordLike THEN 0 ELSE 1 END AS CodeRank,
|
||||
CASE WHEN @keyword <> '' AND AssetName LIKE @keywordLike THEN 0 ELSE 1 END AS NameRank
|
||||
FROM AssetInventory
|
||||
WHERE @keyword = ''
|
||||
OR AssetCode LIKE @keywordLike
|
||||
OR AssetName LIKE @keywordLike
|
||||
OR Model LIKE @keywordLike
|
||||
),
|
||||
OrderedAssets AS (
|
||||
SELECT
|
||||
AssetId,
|
||||
AssetCode,
|
||||
AssetName,
|
||||
Unit,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
CodeRank ASC,
|
||||
NameRank ASC,
|
||||
UpdatedDate DESC,
|
||||
AssetName ASC
|
||||
) AS RowNum
|
||||
FROM FilteredAssets
|
||||
)
|
||||
SELECT AssetId, AssetCode, AssetName, Unit
|
||||
FROM OrderedAssets
|
||||
WHERE RowNum > @offset AND RowNum <= (@offset + @limit)
|
||||
ORDER BY RowNum;
|
||||
|
||||
SELECT COUNT(*) AS TotalCount
|
||||
FROM AssetInventory
|
||||
WHERE @keyword = ''
|
||||
OR AssetCode LIKE @keywordLike
|
||||
OR AssetName LIKE @keywordLike
|
||||
OR Model LIKE @keywordLike;
|
||||
`);
|
||||
|
||||
const rows = Array.isArray(result.recordsets?.[0]) ? result.recordsets[0] : [];
|
||||
const totalCount = Number(result.recordsets?.[1]?.[0]?.TotalCount) || 0;
|
||||
const hasMore = offset + rows.length < totalCount;
|
||||
|
||||
res.json({ success: true, data: rows, hasMore, total: totalCount });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/assets/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.request()
|
||||
@@ -3078,9 +3741,9 @@ async function startServer() {
|
||||
console.log(`\n========================================`);
|
||||
console.log(`AccManager Backend Server`);
|
||||
console.log(`========================================`);
|
||||
console.log(`✓ Server running on http://localhost:${PORT}`);
|
||||
console.log(`✓ Database: AccManager`);
|
||||
console.log(`✓ Default admin: admin / admin`);
|
||||
console.log(`[OK] Server running on http://localhost:${PORT}`);
|
||||
console.log('[OK] Database: AccManager');
|
||||
console.log('[OK] Default admin: admin / admin');
|
||||
console.log(`\nAPI Endpoints:`);
|
||||
console.log(` POST /api/auth/login`);
|
||||
console.log(` GET /api/database/info`);
|
||||
|
||||
Reference in New Issue
Block a user