// Backend Server for AccManager
// Express.js + mssql
const express = require('express');
const sql = require('mssql');
const cors = require('cors');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const multer = require('multer');
const XLSX = require('xlsx');
const dotenv = require('dotenv');
const helmet = require('helmet');
const { rateLimit } = require('express-rate-limit');
dotenv.config();
const APP_TIME_ZONE = process.env.APP_TIME_ZONE || process.env.TZ || 'Asia/Ho_Chi_Minh';
process.env.TZ = APP_TIME_ZONE;
const app = express();
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
function envBool(name, defaultValue) {
const value = process.env[name];
if (value === undefined) {
return defaultValue;
}
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
const DB_SERVER = process.env.DB_SERVER || 'localhost';
const DB_USER = process.env.DB_USER || 'sa';
const DB_PASSWORD = process.env.DB_PASSWORD || '';
const DB_NAME = process.env.DB_NAME || 'AccManager';
const DB_ENCRYPT = envBool('DB_ENCRYPT', IS_PRODUCTION);
const DB_TRUST_CERTIFICATE = envBool('DB_TRUST_CERTIFICATE', !IS_PRODUCTION);
const DB_CONNECT_TIMEOUT = Number(process.env.DB_CONNECT_TIMEOUT || 30000);
const configuredBcryptRounds = Number(process.env.BCRYPT_ROUNDS || 12);
const BCRYPT_ROUNDS = Number.isInteger(configuredBcryptRounds)
? Math.min(15, Math.max(10, configuredBcryptRounds))
: 12;
const DATA_ENCRYPTION_SECRET = String(
process.env.DATA_ENCRYPTION_SECRET
|| process.env.PASSWORD_VIEW_SECRET
|| ''
);
const DATA_ENCRYPTION_KEY = crypto.createHash('sha256').update(DATA_ENCRYPTION_SECRET).digest();
const DATA_ENCRYPTION_PREFIX = 'enc:v2';
const PORT = process.env.PORT || 3000;
const APP_BASE_URL = String(process.env.APP_BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const SESSION_COOKIE_NAME = 'accmanager_session';
const SESSION_TTL_HOURS = Math.min(24, Math.max(1, Number(process.env.SESSION_TTL_HOURS || 12)));
const REMEMBER_SESSION_TTL_DAYS = Math.min(30, Math.max(1, Number(process.env.REMEMBER_SESSION_TTL_DAYS || 14)));
const COOKIE_SECURE = envBool('COOKIE_SECURE', IS_PRODUCTION);
const ALLOW_SELF_REGISTRATION = envBool('ALLOW_SELF_REGISTRATION', false);
const SMTP_HOST = process.env.SMTP_HOST || '';
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_SECURE = envBool('SMTP_SECURE', false);
const SMTP_REQUIRE_TLS = envBool('SMTP_REQUIRE_TLS', IS_PRODUCTION && !SMTP_SECURE);
const SMTP_USER = process.env.SMTP_USER || '';
const SMTP_PASS = process.env.SMTP_PASS || '';
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
let mailTransporter;
if (IS_PRODUCTION && (!process.env.DB_SERVER || !process.env.DB_USER || !DB_PASSWORD)) {
throw new Error('DB_SERVER, DB_USER and DB_PASSWORD are required in production');
}
if (DATA_ENCRYPTION_SECRET.length < 32) {
throw new Error('DATA_ENCRYPTION_SECRET must be configured with at least 32 characters before startup');
}
if (IS_PRODUCTION && !APP_BASE_URL.startsWith('https://')) {
throw new Error('APP_BASE_URL must use HTTPS in production');
}
if (IS_PRODUCTION && !COOKIE_SECURE) {
throw new Error('COOKIE_SECURE must be true in production');
}
if (IS_PRODUCTION && (!DB_ENCRYPT || DB_TRUST_CERTIFICATE)) {
console.warn('[SECURITY] SQL TLS certificate validation is not fully enabled. Set DB_ENCRYPT=true and DB_TRUST_CERTIFICATE=false when the SQL Server has a trusted certificate.');
}
const appTimePartsFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
function getAppTimeParts(value = new Date()) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return appTimePartsFormatter.formatToParts(date).reduce((parts, part) => {
if (part.type !== 'literal') {
parts[part.type] = part.value;
}
return parts;
}, {});
}
function formatAppTimestampForCode(value = new Date(), includeMilliseconds = false) {
const date = value instanceof Date ? value : new Date(value);
const parts = getAppTimeParts(date);
if (!parts) {
return '';
}
const timestamp = [
parts.year,
parts.month,
parts.day,
parts.hour,
parts.minute,
parts.second
].join('');
return includeMilliseconds
? `${timestamp}${String(date.getMilliseconds()).padStart(3, '0')}`
: timestamp;
}
function isBcryptHash(value) {
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value);
}
async function hashPassword(plainPassword) {
return bcrypt.hash(String(plainPassword), BCRYPT_ROUNDS);
}
async function verifyPassword(plainPassword, storedPassword) {
if (isBcryptHash(storedPassword)) {
return bcrypt.compare(String(plainPassword), storedPassword);
}
// Legacy fallback for old plain-text records.
return String(plainPassword) === String(storedPassword || '');
}
function encryptSensitiveValue(plainValue) {
if (plainValue === null || plainValue === undefined || plainValue === '') {
return '';
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', DATA_ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(String(plainValue), 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
return `${DATA_ENCRYPTION_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
function decryptSensitiveValue(payload) {
try {
if (typeof payload !== 'string' || !payload.startsWith(`${DATA_ENCRYPTION_PREFIX}:`)) {
return null;
}
const parts = payload.split(':');
if (parts.length !== 5) {
return null;
}
const iv = Buffer.from(parts[2], 'base64');
const tag = Buffer.from(parts[3], 'base64');
const encrypted = Buffer.from(parts[4], 'base64');
if (
iv.length !== 12
|| tag.length !== 16
|| encrypted.length === 0
|| iv.toString('base64') !== parts[2]
|| tag.toString('base64') !== parts[3]
|| encrypted.toString('base64') !== parts[4]
) {
return null;
}
const decipher = crypto.createDecipheriv('aes-256-gcm', DATA_ENCRYPTION_KEY, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return plain.toString('utf8');
} catch (err) {
return null;
}
}
function hashVerificationToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function generateEmailVerificationToken() {
const token = crypto.randomBytes(32).toString('hex');
return {
token,
tokenHash: hashVerificationToken(token)
};
}
function getEmailVerificationUrl(token) {
return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`;
}
function getPasswordResetUrl(token) {
return `${APP_BASE_URL}/pages/login.html?mode=reset-password&token=${encodeURIComponent(token)}`;
}
function canSendEmails() {
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS);
}
function getMailTransporter() {
if (!mailTransporter) {
mailTransporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
requireTLS: SMTP_REQUIRE_TLS,
disableFileAccess: true,
disableUrlAccess: true,
auth: {
user: SMTP_USER,
pass: SMTP_PASS
}
});
}
return mailTransporter;
}
async function sendVerificationEmail({ email, username, token }) {
const verifyUrl = getEmailVerificationUrl(token);
const safeUsername = escapeHtml(username || 'there');
const safeVerifyUrl = escapeHtml(verifyUrl);
if (!canSendEmails()) {
console.warn(`SMTP is not configured. Cannot send verification email to ${email}.`);
return {
sent: false,
...(!IS_PRODUCTION ? { previewUrl: verifyUrl } : {}),
reason: 'SMTP is not configured'
};
}
try {
const transporter = getMailTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: email,
subject: 'AccManager - Confirm your email',
text: `Hello ${username || 'there'},\n\nPlease confirm your email by opening this link:\n${verifyUrl}\n\nThis link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not register, please ignore this email.`,
html: `
Confirm your email
Hello ${safeUsername},
Thank you for registering. Please confirm your email by clicking the button below:
Confirm Email
Or copy this URL into your browser:
${safeVerifyUrl}
This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.
If you did not register this account, you can ignore this message.
`
});
return { sent: true };
} catch (err) {
console.error('Send verification email error:', err.message);
return {
sent: false,
reason: err.message
};
}
}
async function sendPasswordResetEmail({ email, username, token }) {
const resetUrl = getPasswordResetUrl(token);
const safeUsername = escapeHtml(username || 'there');
const safeResetUrl = escapeHtml(resetUrl);
if (!canSendEmails()) {
console.warn(`SMTP is not configured. Cannot send password reset email to ${email}.`);
return {
sent: false,
...(!IS_PRODUCTION ? { previewUrl: resetUrl } : {}),
reason: 'SMTP is not configured'
};
}
try {
const transporter = getMailTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: email,
subject: 'AccManager - Reset your password',
text: `Hello ${username || 'there'},\n\nA password reset was requested for your account.\nOpen this link to set a new password:\n${resetUrl}\n\nThis link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not request this, please ignore this email.`,
html: `
Reset your password
Hello ${safeUsername},
We received a password reset request for your account.
Reset Password
Or copy this URL into your browser:
${safeResetUrl}
This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.
If you did not request this reset, you can ignore this message.
`
});
return { sent: true };
} catch (err) {
console.error('Send password reset email error:', err.message);
return {
sent: false,
reason: err.message
};
}
}
function getUserIdFromRequest(req) {
const userId = Number(req.user?.UserId);
return Number.isInteger(userId) && userId > 0 ? userId : null;
}
function getRequesterRole(req) {
return normalizeRole(req.user?.Role || req.user?.RoleName);
}
function sendInternalError(res, err, publicMessage = 'Internal server error') {
console.error('Request failed:', err?.message || err);
return res.status(500).json({ success: false, message: publicMessage });
}
function normalizeOptionalHttpUrl(value) {
const text = String(value || '').trim();
if (!text) {
return '';
}
try {
const parsed = new URL(text);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.toString() : null;
} catch (err) {
return null;
}
}
async function getUserDisplayNameById(userId) {
if (!userId) {
return null;
}
try {
const result = await pool.request()
.input('userId', sql.Int, userId)
.query(`
SELECT TOP 1
NULLIF(LTRIM(RTRIM(FullName)), '') AS FullName,
NULLIF(LTRIM(RTRIM(Username)), '') AS Username
FROM Users
WHERE UserId = @userId
`);
const user = result.recordset?.[0];
return user?.FullName || user?.Username || null;
} catch (err) {
return null;
}
}
function normalizeDepartmentName(value) {
return String(value || '').trim();
}
function normalizeProjectName(value) {
return String(value || '').trim();
}
async function syncAssetDepartmentsFromInventory() {
if (!pool) {
return;
}
await pool.request().query(`
WITH SourceDepartments AS (
SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName
FROM AssetInventory
WHERE Department IS NOT NULL
AND LTRIM(RTRIM(Department)) <> ''
)
INSERT INTO AssetDepartments (DepartmentName)
SELECT source.DepartmentName
FROM SourceDepartments source
WHERE NOT EXISTS (
SELECT 1
FROM AssetDepartments target
WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName)
);
`);
}
async function syncAssetProjectsFromInventory() {
if (!pool) {
return;
}
await pool.request().query(`
WITH SourceProjects AS (
SELECT DISTINCT LTRIM(RTRIM(Project)) AS ProjectName
FROM AssetInventory
WHERE Project IS NOT NULL
AND LTRIM(RTRIM(Project)) <> ''
)
INSERT INTO AssetProjects (ProjectName)
SELECT source.ProjectName
FROM SourceProjects source
WHERE NOT EXISTS (
SELECT 1
FROM AssetProjects target
WHERE LOWER(LTRIM(RTRIM(target.ProjectName))) = LOWER(source.ProjectName)
);
`);
}
async function ensurePasswordResetColumns() {
if (!pool) {
return;
}
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
}
async function ensureDepartmentExists(departmentName) {
const normalized = normalizeDepartmentName(departmentName);
if (!normalized || !pool) {
return;
}
await pool.request()
.input('departmentName', sql.NVarChar, normalized)
.query(`
IF NOT EXISTS (
SELECT 1
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
)
BEGIN
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
END
`);
}
function parsePositiveInteger(value, fallback = 1) {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
return fallback;
}
function parseNonNegativeInteger(value, fallback = 0) {
const parsed = parseAssetImportNumericValue(value, Number.NaN);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseOptionalNonNegativeInteger(value) {
if (value === undefined || value === null || String(value).trim() === '') {
return null;
}
const parsed = parseAssetImportNumericValue(value, Number.NaN);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return null;
}
function parseNonNegativeIntegerOrFallback(value, fallback = 0) {
const parsed = parseOptionalNonNegativeInteger(value);
if (parsed !== null) {
return parsed;
}
return fallback;
}
function parseNullableDecimal(value) {
if (value === undefined || value === null) {
return null;
}
const normalized = String(value).trim().replace(/,/g, '');
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function parseNullableDate(value) {
if (value === undefined || value === null) {
return null;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
const directDate = new Date(raw);
if (!Number.isNaN(directDate.getTime())) {
return directDate;
}
const localDateParts = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$/);
if (localDateParts) {
const day = Number(localDateParts[1]);
const month = Number(localDateParts[2]);
let year = Number(localDateParts[3]);
if (year < 100) {
year += 2000;
}
const localizedDate = new Date(year, month - 1, day);
if (!Number.isNaN(localizedDate.getTime())) {
return localizedDate;
}
}
return null;
}
function normalizeAssetStatus(value) {
const normalized = String(value || '').trim().toLowerCase();
if (['exported', 'da xuat'].includes(normalized)) {
return 'exported';
}
if (['in_use', 'in use', 'dang su dung', 'active'].includes(normalized)) {
return 'in_use';
}
if (['maintenance', 'bao tri'].includes(normalized)) {
return 'maintenance';
}
if (['disposed', 'thanh ly', 'retired'].includes(normalized)) {
return 'disposed';
}
if (['in_stock', 'in stock', 'ton kho', 'warehouse'].includes(normalized)) {
return 'in_stock';
}
return 'in_use';
}
function resolveAssetStatusFromStock(endingBalance, borrowingQuantity) {
const ending = parseNonNegativeIntegerOrFallback(endingBalance, 0);
const borrowing = parseNonNegativeIntegerOrFallback(borrowingQuantity, 0);
if (ending <= 0) {
return 'exported';
}
if (borrowing > 0) {
return 'in_use';
}
return 'in_stock';
}
function normalizeAssetStockBuckets(endingBalance, proposedNewQuantity, proposedUsedQuantity) {
const ending = parseNonNegativeIntegerOrFallback(endingBalance, 0);
let newQuantity = parseNonNegativeIntegerOrFallback(proposedNewQuantity, ending);
let usedQuantity = parseNonNegativeIntegerOrFallback(proposedUsedQuantity, 0);
const currentTotal = newQuantity + usedQuantity;
if (currentTotal < ending) {
newQuantity += (ending - currentTotal);
} else if (currentTotal > ending) {
let overflow = currentTotal - ending;
const reduceFromNew = Math.min(newQuantity, overflow);
newQuantity -= reduceFromNew;
overflow -= reduceFromNew;
if (overflow > 0) {
usedQuantity = Math.max(usedQuantity - overflow, 0);
}
}
return {
newQuantity: Math.max(newQuantity, 0),
usedQuantity: Math.max(usedQuantity, 0)
};
}
function normalizeAssetPayload(payload = {}) {
const assetName = String(payload.assetName || '').trim();
const model = String(payload.model || '').trim();
const assetCode = String(payload.assetCode || '').trim();
const quantity = parseNonNegativeIntegerOrFallback(payload.quantity, 0);
const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0);
const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0);
const providedEndingBalance = parseOptionalNonNegativeInteger(payload.endingBalance);
const endingBalance = providedEndingBalance !== null
? providedEndingBalance
: Math.max(quantity + importInPeriod - exportInPeriod, 0);
const providedNewQuantity = parseOptionalNonNegativeInteger(payload.newQuantity);
const providedUsedQuantity = parseOptionalNonNegativeInteger(payload.usedQuantity);
const stockBuckets = normalizeAssetStockBuckets(
endingBalance,
providedNewQuantity !== null ? providedNewQuantity : endingBalance,
providedUsedQuantity !== null ? providedUsedQuantity : 0
);
const status = resolveAssetStatusFromStock(endingBalance, exportInPeriod);
return {
assetCode,
assetName: assetName || model || assetCode || null,
model: model || null,
serialNumber: String(payload.serialNumber || '').trim() || null,
quantity,
unit: String(payload.unit || '').trim() || null,
department: String(payload.department || '').trim() || null,
project: String(payload.project || '').trim() || null,
importInPeriod,
exportInPeriod,
endingBalance,
newQuantity: stockBuckets.newQuantity,
usedQuantity: stockBuckets.usedQuantity,
location: String(payload.location || '').trim() || null,
custodian: String(payload.custodian || '').trim() || null,
borrower: String(payload.borrower || '').trim() || null,
purchaseDate: parseNullableDate(payload.purchaseDate),
purchasePrice: parseNullableDecimal(payload.purchasePrice),
status,
notes: String(payload.notes || '').trim() || null
};
}
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 === 'returned' || normalized === 'return_done' || normalized === 'done' || normalized === 'da_tra') {
return 'returned';
}
if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') {
return 'rejected';
}
return 'pending';
}
function normalizeImportToken(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u0111\u0110]/g, 'd')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function isEncryptedSensitiveValue(value) {
return typeof value === 'string' && value.startsWith(`${DATA_ENCRYPTION_PREFIX}:`);
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function normalizeAssetDamageType(value) {
const normalized = normalizeImportToken(value);
if (['disposed', 'dispose', 'disposal', 'thanhly', 'liquidated', 'liquidation'].includes(normalized)) {
return 'disposed';
}
return 'damaged';
}
function getAssetDamageTypeLabel(value) {
return normalizeAssetDamageType(value) === 'disposed' ? 'Thanh lý' : 'Hỏng';
}
function isHeaderLikeAssetImportRow(row = {}) {
const headerTokens = new Set([
'stt',
'ngayve',
'mavattu',
'mavt',
'mataisan',
'mats',
'matscd',
'tentaisan',
'tenlinhkiensp',
'model',
'dvt',
'donvi',
'tondauky',
'tondauki',
'nhaptrongky',
'nhaptrongki',
'xuattrongky',
'xuattrongki',
'toncuoiky',
'toncuoiki',
'lidoxuat',
'lydoxuat',
'tinhtrang',
'vitri',
'duan',
'assetcode',
'assetname',
'quantity',
'importinperiod',
'exportinperiod',
'endingbalance',
'unit',
'location',
'department',
'project',
'status',
'notes'
]);
const fields = [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.status,
row.location,
row.department,
row.project,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance,
row.notes
];
const headerLikeCount = fields.reduce((count, value) => {
const token = normalizeImportToken(value);
return count + (token && headerTokens.has(token) ? 1 : 0);
}, 0);
return headerLikeCount >= 2;
}
function isMeaningfulImportedAssetRow(row = {}) {
return [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.location,
row.department,
row.project,
row.notes,
row.quantity,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance
].some(value => String(value ?? '').trim() !== '');
}
const ASSET_IMPORT_ALIASES = {
stt: ['STT', 'So thu tu'],
assetCode: ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'],
assetName: ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'],
model: ['Model', 'Dong may'],
serialNumber: ['Serial Number', 'Serial', 'So serial', 'So seri'],
quantity: ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL'],
importInPeriod: ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap'],
exportInPeriod: ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat'],
endingBalance: ['Ton cuoi ky', 'Ton cuoi ki', 'Ton cuoi', 'Ending Balance'],
unit: ['Unit', 'Don vi', 'DVT'],
department: ['Department', 'Bo phan', 'Phong ban'],
project: ['Project', 'Du an', 'Cong trinh'],
location: ['Location', 'Vi tri', 'Noi dat'],
custodian: ['Custodian', 'Nguoi quan ly', 'Nguoi su dung'],
purchaseDate: ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve'],
purchasePrice: ['Purchase Price', 'Gia mua', 'Don gia'],
status: ['Status', 'Trang thai', 'Tinh trang'],
notes: ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat']
};
function inferAssetFieldFromHeaderToken(headerToken) {
const token = String(headerToken || '');
if (!token) {
return null;
}
if (token.includes('model')) return 'model';
if (token.includes('serial') || token.includes('seri')) return 'serialNumber';
if (token.includes('tondau')) return 'quantity';
if (token.includes('nhaptrongky') || token.includes('nhaptrongki')) return 'importInPeriod';
if (token.includes('xuattrongky') || token.includes('xuattrongki')) return 'exportInPeriod';
if (token.includes('toncuoi')) return 'endingBalance';
if (token.includes('donvi') || token.includes('dvt') || token === 'unit') return 'unit';
if (token.includes('vitri') || token.includes('location')) return 'location';
if (token.includes('tinhtrang') || token === 'status') return 'status';
if (token.includes('duan') || token.includes('project')) return 'project';
if (token.includes('phongban') || token.includes('bophan') || token.includes('department')) return 'department';
if (token.includes('lydoxuat') || token.includes('lidoxuat') || token.includes('ghichu') || token === 'notes') return 'notes';
if (token.includes('soluong') || token === 'sl' || token === 'quantity') return 'quantity';
const hasTen = token.includes('ten');
const hasMa = token.includes('ma');
const hasAssetLike = token.includes('linhkien') || token.includes('vattu') || token.includes('sanpham') || token.includes('taisan') || token.includes('sp');
if (hasTen && hasAssetLike) return 'assetName';
if (hasMa && hasAssetLike) return 'assetCode';
return null;
}
function resolveAssetImportFieldByHeader(headerCell) {
const token = normalizeImportToken(headerCell);
if (!token) {
return null;
}
let bestField = null;
let bestScore = 0;
for (const [field, aliases] of Object.entries(ASSET_IMPORT_ALIASES)) {
for (const alias of aliases) {
const aliasToken = normalizeImportToken(alias);
if (!aliasToken) {
continue;
}
let score = 0;
if (token === aliasToken) {
score = 5;
} else if (token.includes(aliasToken) || aliasToken.includes(token)) {
score = 3;
}
if (score > bestScore) {
bestScore = score;
bestField = field;
}
}
}
if (bestField) {
return bestField;
}
return inferAssetFieldFromHeaderToken(token);
}
function buildAssetImportFieldMapFromHeaderRow(headerRow) {
const row = Array.isArray(headerRow) ? headerRow : [];
const fieldMap = {};
for (let index = 0; index < row.length; index += 1) {
const field = resolveAssetImportFieldByHeader(row[index]);
if (!field) {
continue;
}
if (fieldMap[field] === undefined) {
fieldMap[field] = index;
}
}
return fieldMap;
}
function scoreAssetImportFieldMap(fieldMap = {}) {
let score = 0;
const keys = Object.keys(fieldMap);
score += keys.length;
if (fieldMap.assetName !== undefined) score += 5;
if (fieldMap.assetCode !== undefined) score += 4;
if (fieldMap.model !== undefined) score += 3;
if (fieldMap.endingBalance !== undefined) score += 2;
if (fieldMap.importInPeriod !== undefined) score += 1;
if (fieldMap.exportInPeriod !== undefined) score += 1;
if (fieldMap.quantity !== undefined) score += 2;
if (fieldMap.unit !== undefined) score += 1;
if (fieldMap.location !== undefined) score += 1;
if (fieldMap.project !== undefined) score += 1;
return score;
}
function parseAssetImportRowsByHeaderMap(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 300);
let bestHeaderRowIndex = -1;
let bestFieldMap = {};
let bestScore = 0;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const headerRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
if (!headerRow.some(cell => String(cell ?? '').trim() !== '')) {
continue;
}
const candidateMap = buildAssetImportFieldMapFromHeaderRow(headerRow);
const score = scoreAssetImportFieldMap(candidateMap);
if (score > bestScore) {
bestScore = score;
bestHeaderRowIndex = rowIndex;
bestFieldMap = candidateMap;
}
}
if (bestHeaderRowIndex < 0 || bestScore < 2) {
return [];
}
const pick = (row, index) => {
if (!Array.isArray(row) || index === undefined || index < 0) {
return '';
}
return row[index] ?? '';
};
const parsed = rows
.slice(bestHeaderRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const endingBalance = parseAssetImportNumericValue(
pick(row, bestFieldMap.endingBalance),
0
);
const mapped = {
sourceStt: parseAssetImportSttNumber(pick(row, bestFieldMap.stt)),
assetCode: String(pick(row, bestFieldMap.assetCode)).trim(),
assetName: String(pick(row, bestFieldMap.assetName)).trim(),
model: String(pick(row, bestFieldMap.model)).trim(),
serialNumber: String(pick(row, bestFieldMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, bestFieldMap.quantity), 0),
importInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.importInPeriod), 0),
exportInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.exportInPeriod), 0),
endingBalance,
unit: String(pick(row, bestFieldMap.unit)).trim(),
department: String(pick(row, bestFieldMap.department)).trim(),
project: String(pick(row, bestFieldMap.project)).trim(),
location: String(pick(row, bestFieldMap.location)).trim(),
custodian: String(pick(row, bestFieldMap.custodian)).trim(),
purchaseDate: pick(row, bestFieldMap.purchaseDate),
purchasePrice: pick(row, bestFieldMap.purchasePrice),
status: String(pick(row, bestFieldMap.status)).trim(),
notes: String(pick(row, bestFieldMap.notes)).trim()
};
const hasAnyCoreValue = [mapped.assetCode, mapped.assetName, mapped.model, mapped.location, mapped.notes]
.some(value => String(value || '').trim() !== '');
if (!hasAnyCoreValue) {
return null;
}
return finalizeImportedAssetPayload(mapped, bestHeaderRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
return parsed;
}
function isAssetImportHeaderMatch(actualHeader, alias) {
const normalizedHeader = normalizeImportToken(actualHeader);
const normalizedAlias = normalizeImportToken(alias);
if (!normalizedHeader || !normalizedAlias) {
return false;
}
if (normalizedHeader === normalizedAlias) {
return true;
}
if (normalizedAlias.length < 4 || normalizedHeader.length < 4) {
return false;
}
return normalizedHeader.includes(normalizedAlias) || normalizedAlias.includes(normalizedHeader);
}
function inferAssetImportColumnIndex(headerRow, aliases = []) {
const row = Array.isArray(headerRow) ? headerRow : [];
for (let index = 0; index < row.length; index += 1) {
if (aliases.some(alias => isAssetImportHeaderMatch(row[index], alias))) {
return index;
}
}
return -1;
}
function parseAssetImportNumericValue(value, fallback = 0) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const raw = String(value).trim();
if (!raw) {
return fallback;
}
const compact = raw.replace(/\s+/g, '');
let normalized = compact;
const hasDot = compact.includes('.');
const hasComma = compact.includes(',');
// vi-VN style: 1.234,56 or 1.234
if (hasDot && hasComma) {
if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(compact)) {
normalized = compact.replace(/\./g, '').replace(',', '.');
} else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(compact)) {
// en-US style: 1,234.56
normalized = compact.replace(/,/g, '');
} else {
normalized = compact.replace(/,/g, '');
}
} else if (hasDot) {
if (/^-?\d{1,3}(\.\d{3})+$/.test(compact)) {
normalized = compact.replace(/\./g, '');
}
} else if (hasComma) {
if (/^-?\d{1,3}(,\d{3})+$/.test(compact)) {
normalized = compact.replace(/,/g, '');
} else if (/^-?\d+,\d+$/.test(compact)) {
normalized = compact.replace(',', '.');
} else {
normalized = compact.replace(/,/g, '');
}
}
if (!normalized) {
return fallback;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseAssetImportSttNumber(value) {
const raw = String(value ?? '')
.trim()
.replace(/\.$/, '')
.replace(',', '.');
if (!raw) {
return null;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
const rounded = Math.round(parsed);
return Math.abs(parsed - rounded) < 1e-9 ? rounded : null;
}
function sanitizeAssetCodeToken(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u0111\u0110]/g, 'd')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
function generateImportAssetCodeFromRow(mapped, rowNumber = 0) {
const fromModel = sanitizeAssetCodeToken(mapped.model);
const fromSerial = sanitizeAssetCodeToken(mapped.serialNumber);
const fromName = sanitizeAssetCodeToken(mapped.assetName);
const base = fromModel || fromSerial || fromName || 'ASSET';
const sttNumber = parseAssetImportSttNumber(mapped?.sourceStt);
const suffixSeed = sttNumber || rowNumber || 0;
const suffix = String(suffixSeed).padStart(4, '0');
return `IMP-${base}-${suffix}`;
}
function generateManualAssetCodeFromPayload(payload = {}) {
const fromModel = sanitizeAssetCodeToken(payload.model);
const fromSerial = sanitizeAssetCodeToken(payload.serialNumber);
const fromName = sanitizeAssetCodeToken(payload.assetName);
const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32);
const timestamp = formatAppTimestampForCode(new Date(), true);
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`;
}
async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = generateManualAssetCodeFromPayload(payload);
const existed = await pool.request()
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
}
throw new Error('Cannot generate unique asset code');
}
function finalizeImportedAssetPayload(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.assetName) {
result.assetName = String(result.model || result.serialNumber || result.assetCode || '').trim();
}
if (!result.assetCode && result.assetName) {
result.assetCode = generateImportAssetCodeFromRow(result, rowNumber);
}
return result;
}
function buildAssetImportIndexMap(headerRow) {
const indexMap = {
stt: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.stt),
assetCode: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetCode),
assetName: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetName),
model: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.model),
serialNumber: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.serialNumber),
quantity: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.quantity),
importInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.importInPeriod),
exportInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.exportInPeriod),
endingBalance: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.endingBalance),
unit: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.unit),
department: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.department),
project: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.project),
location: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.location),
custodian: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.custodian),
purchaseDate: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchaseDate),
purchasePrice: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchasePrice),
status: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.status),
notes: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.notes)
};
if (indexMap.stt >= 0) {
if (indexMap.purchaseDate < 0) indexMap.purchaseDate = indexMap.stt + 1;
if (indexMap.assetCode < 0) indexMap.assetCode = indexMap.stt + 2;
if (indexMap.assetName < 0) indexMap.assetName = indexMap.stt + 3;
if (indexMap.model < 0) indexMap.model = indexMap.stt + 4;
if (indexMap.unit < 0) indexMap.unit = indexMap.stt + 5;
if (indexMap.quantity < 0) indexMap.quantity = indexMap.stt + 6;
if (indexMap.importInPeriod < 0) indexMap.importInPeriod = indexMap.stt + 7;
if (indexMap.exportInPeriod < 0) indexMap.exportInPeriod = indexMap.stt + 8;
if (indexMap.endingBalance < 0) indexMap.endingBalance = indexMap.stt + 9;
if (indexMap.notes < 0) indexMap.notes = indexMap.stt + 10;
if (indexMap.status < 0) indexMap.status = indexMap.stt + 11;
if (indexMap.location < 0) indexMap.location = indexMap.stt + 12;
if (indexMap.project < 0) indexMap.project = indexMap.stt + 13;
}
return indexMap;
}
function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) {
const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : [];
if (!headerRow.length) {
return [];
}
const indexMap = buildAssetImportIndexMap(headerRow);
if (indexMap.assetCode < 0 && indexMap.assetName < 0 && indexMap.model < 0 && indexMap.stt < 0) {
return [];
}
const pick = (row, index) => {
if (index < 0 || !Array.isArray(row)) {
return '';
}
return row[index] ?? '';
};
return matrixRows
.slice(headerRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const sttValue = parseAssetImportSttNumber(pick(row, indexMap.stt));
if (indexMap.stt >= 0 && sttValue === null) {
return null;
}
const endingBalance = parseAssetImportNumericValue(
pick(row, indexMap.endingBalance),
0
);
const mapped = {
sourceStt: sttValue,
assetCode: String(pick(row, indexMap.assetCode)).trim(),
assetName: String(pick(row, indexMap.assetName)).trim(),
model: String(pick(row, indexMap.model)).trim(),
serialNumber: String(pick(row, indexMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, indexMap.quantity), 0),
importInPeriod: parseAssetImportNumericValue(pick(row, indexMap.importInPeriod), 0),
exportInPeriod: parseAssetImportNumericValue(pick(row, indexMap.exportInPeriod), 0),
endingBalance,
unit: String(pick(row, indexMap.unit)).trim(),
department: String(pick(row, indexMap.department)).trim(),
project: String(pick(row, indexMap.project)).trim(),
location: String(pick(row, indexMap.location)).trim(),
custodian: String(pick(row, indexMap.custodian)).trim(),
purchaseDate: pick(row, indexMap.purchaseDate),
purchasePrice: pick(row, indexMap.purchasePrice),
status: String(pick(row, indexMap.status)).trim(),
notes: String(pick(row, indexMap.notes)).trim()
};
return finalizeImportedAssetPayload(mapped, headerRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function parseAssetImportRowsFromMatrix(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 300);
let bestRows = [];
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
if (!row.length) {
continue;
}
const hasStt = row.some(cell => ASSET_IMPORT_ALIASES.stt.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasName = row.some(cell => ASSET_IMPORT_ALIASES.assetName.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasModel = row.some(cell => ASSET_IMPORT_ALIASES.model.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasQty = row.some(cell => ASSET_IMPORT_ALIASES.quantity.some(alias => isAssetImportHeaderMatch(cell, alias)));
if (!hasStt || (!hasName && !hasModel && !hasQty)) {
continue;
}
const candidateRows = mapAssetImportMatrixRowsByIndex(rows, rowIndex);
if (candidateRows.length > bestRows.length) {
bestRows = candidateRows;
}
}
if (bestRows.length >= 3) {
return bestRows;
}
let detectedSttCol = -1;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
const col = inferAssetImportColumnIndex(row, ASSET_IMPORT_ALIASES.stt);
if (col >= 0) {
detectedSttCol = col;
break;
}
}
if (detectedSttCol < 0) {
detectedSttCol = 0;
}
const sttRows = rows.filter(row => {
if (!Array.isArray(row)) {
return false;
}
const sttValue = parseAssetImportSttNumber(row[detectedSttCol]);
if (sttValue === null) {
return false;
}
return [2, 3, 4, 5, 9, 12]
.map(offset => detectedSttCol + offset)
.some(index => String(row[index] ?? '').trim() !== '');
});
if (sttRows.length < 3) {
return bestRows;
}
return sttRows
.map((row, idx) => {
const sttValue = parseAssetImportSttNumber(row[detectedSttCol]);
const endingBalance = parseAssetImportNumericValue(row[detectedSttCol + 9] ?? '', 0);
const mapped = {
sourceStt: sttValue,
assetCode: String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
assetName: String(row[detectedSttCol + 3] ?? '').trim() || String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
model: String(row[detectedSttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[detectedSttCol + 6] ?? '', 0),
importInPeriod: parseAssetImportNumericValue(row[detectedSttCol + 7] ?? '', 0),
exportInPeriod: parseAssetImportNumericValue(row[detectedSttCol + 8] ?? '', 0),
endingBalance,
unit: String(row[detectedSttCol + 5] ?? '').trim(),
department: '',
project: String(row[detectedSttCol + 13] ?? '').trim(),
location: String(row[detectedSttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[detectedSttCol + 1] ?? '',
purchasePrice: '',
status: String(row[detectedSttCol + 11] ?? '').trim(),
notes: String(row[detectedSttCol + 10] ?? '').trim()
};
return finalizeImportedAssetPayload(mapped, idx + 2);
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function detectLikelySttColumn(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxCols = Math.min(
rows.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0),
40
);
let bestColumn = -1;
let bestScore = 0;
const scoreColumn = col => {
let validCount = 0;
let sequentialHits = 0;
let prev = null;
for (const row of rows.slice(0, 500)) {
if (!Array.isArray(row)) {
continue;
}
const value = parseAssetImportSttNumber(row[col]);
if (value === null) {
continue;
}
validCount += 1;
if (prev !== null && value === prev + 1) {
sequentialHits += 1;
}
prev = value;
}
return (validCount * 2) + (sequentialHits * 5);
};
for (let col = 0; col < maxCols; col += 1) {
const score = scoreColumn(col);
if (score > bestScore) {
bestScore = score;
bestColumn = col;
}
}
return bestScore >= 12 ? bestColumn : -1;
}
function parseAssetImportRowsLoose(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const sttCol = detectLikelySttColumn(rows);
if (sttCol < 0) {
return [];
}
const dataRows = rows.filter(row => {
if (!Array.isArray(row)) {
return false;
}
const stt = parseAssetImportSttNumber(row[sttCol]);
if (stt === null) {
return false;
}
const hasCoreValue = [2, 3, 4, 5, 9, 12]
.map(offset => sttCol + offset)
.some(index => String(row[index] ?? '').trim() !== '');
return hasCoreValue;
});
return dataRows
.map((row, idx) => {
const sttValue = parseAssetImportSttNumber(row[sttCol]);
const endingBalance = parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0);
const mapped = {
sourceStt: sttValue,
assetCode: String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(),
assetName: String(row[sttCol + 3] ?? '').trim() || String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(),
model: String(row[sttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[sttCol + 6] ?? '', 0),
importInPeriod: parseAssetImportNumericValue(row[sttCol + 7] ?? '', 0),
exportInPeriod: parseAssetImportNumericValue(row[sttCol + 8] ?? '', 0),
endingBalance,
unit: String(row[sttCol + 5] ?? '').trim(),
department: '',
project: String(row[sttCol + 13] ?? '').trim(),
location: String(row[sttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[sttCol + 1] ?? '',
purchasePrice: '',
status: String(row[sttCol + 11] ?? '').trim(),
notes: String(row[sttCol + 10] ?? '').trim()
};
return finalizeImportedAssetPayload(mapped, idx + 2);
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function parseAssetImportRows(matrixRows) {
const genericRows = parseAssetImportRowsByHeaderMap(matrixRows);
if (genericRows.length > 0) {
return genericRows;
}
const strictRows = parseAssetImportRowsFromMatrix(matrixRows);
if (strictRows.length > 0) {
return strictRows;
}
return parseAssetImportRowsLoose(matrixRows);
}
function countNonEmptyMatrixRows(matrixRows) {
return (Array.isArray(matrixRows) ? matrixRows : []).filter(
row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')
).length;
}
async function ensureUniqueImportAssetCode(transaction, initialCode, maxAttempts = 12) {
let candidate = String(initialCode || '').trim();
if (!candidate) {
candidate = generateManualAssetCodeFromPayload({});
}
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const existed = await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
const suffix = String(attempt + 1).padStart(2, '0');
candidate = `${String(initialCode || 'IMP-ASSET').slice(0, 90)}-${suffix}`;
}
return generateManualAssetCodeFromPayload({ assetName: initialCode || 'IMPORT' });
}
function scoreAssetImportSheetName(sheetName = '') {
const token = normalizeImportToken(sheetName);
if (!token) {
return 0;
}
let score = 0;
if (token.includes('xuatnhapton')) score += 120;
if (token.includes('kho')) score += 60;
if (token.includes('robotics')) score += 40;
if (token.includes('inventory')) score += 40;
if (token.includes('asset')) score += 30;
return score;
}
function parseAssetImportRowsFromWorkbook(workbook) {
const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : [];
let bestRows = [];
let bestSheetName = '';
let bestNonEmptyRows = 0;
let bestSheetPriority = 0;
const diagnostics = [];
for (const sheetName of sheetNames) {
const sheet = workbook.Sheets?.[sheetName];
if (!sheet) {
continue;
}
const matrixRows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false
});
const parsedRows = parseAssetImportRows(matrixRows);
const nonEmptyRows = countNonEmptyMatrixRows(matrixRows);
const sheetPriority = scoreAssetImportSheetName(sheetName);
diagnostics.push({
sheetName,
parsedRows: parsedRows.length,
nonEmptyRows,
sheetPriority
});
if (
(sheetPriority > bestSheetPriority && parsedRows.length > 0)
|| (sheetPriority === bestSheetPriority && parsedRows.length > bestRows.length)
|| (sheetPriority === bestSheetPriority && parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)
) {
bestRows = parsedRows;
bestSheetName = sheetName;
bestNonEmptyRows = nonEmptyRows;
bestSheetPriority = sheetPriority;
}
}
return {
rows: bestRows,
sheetName: bestSheetName,
diagnostics
};
}
const CONSUMABLE_IMPORT_ALIASES = {
stt: ['STT', 'So thu tu'],
requestMonth: ['Thang de xuat', 'Thang', 'Ky de xuat', 'Ky'],
consumableCode: ['Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN'],
consumableName: ['Ten linh kien/sp', 'Ten linh kien sp', 'Ten linh kien', 'Ten vat tu', 'Ten sp', 'Ten san pham', 'Name', 'Dien giai', 'Mo ta'],
model: ['Model', 'Dong may', 'Quy cach'],
unit: ['DVT', 'Don vi', 'Unit'],
openingBalance: ['Ton dau ky', 'Ton dau ki', 'Ton dau', 'Opening Balance', 'Quantity', 'So luong', 'SL'],
importInPeriod: ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap'],
exportInPeriod: ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat'],
endingBalance: ['Ton cuoi ky', 'Ton cuoi ki', 'Ton cuoi', 'Ending Balance'],
exportReason: ['Li do xuat', 'Ly do xuat', 'Lí do xuất', 'Ghi chu', 'Ghi chú', 'Notes']
};
function normalizeConsumablePayload(payload = {}) {
const consumableName = String(payload.consumableName || payload.assetName || '').trim();
const model = String(payload.model || '').trim();
const consumableCode = String(payload.consumableCode || payload.assetCode || '').trim();
const openingBalance = parseNonNegativeIntegerOrFallback(payload.openingBalance ?? payload.quantity, 0);
const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0);
const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0);
const providedEndingBalance = parseOptionalNonNegativeInteger(payload.endingBalance);
const endingBalance = providedEndingBalance !== null
? providedEndingBalance
: Math.max(openingBalance + importInPeriod - exportInPeriod, 0);
return {
requestMonth: String(payload.requestMonth || '').trim() || null,
consumableCode,
consumableName: consumableName || model || consumableCode || null,
model: model || null,
unit: String(payload.unit || '').trim() || null,
openingBalance,
importInPeriod,
exportInPeriod,
endingBalance,
exportReason: String(payload.exportReason || payload.notes || '').trim() || null
};
}
function isHeaderLikeConsumableImportRow(row = {}) {
const headerTokens = new Set([
'stt',
'thangdexuat',
'mavattu',
'mavt',
'tenlinhkiensp',
'tenlinhkien',
'tenvattu',
'model',
'dvt',
'donvi',
'tondauky',
'tondauki',
'nhaptrongky',
'nhaptrongki',
'xuattrongky',
'xuattrongki',
'toncuoiky',
'toncuoiki',
'lidoxuat',
'lydoxuat'
]);
const fields = [
row.requestMonth,
row.consumableCode,
row.consumableName,
row.model,
row.unit,
row.openingBalance,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance,
row.exportReason
];
const headerLikeCount = fields.reduce((count, value) => {
const token = normalizeImportToken(value);
return count + (token && headerTokens.has(token) ? 1 : 0);
}, 0);
return headerLikeCount >= 2;
}
function isMeaningfulImportedConsumableRow(row = {}) {
return [
row.requestMonth,
row.consumableCode,
row.consumableName,
row.model,
row.unit,
row.exportReason,
row.openingBalance,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance
].some(value => String(value ?? '').trim() !== '');
}
function inferConsumableFieldFromHeaderToken(headerToken) {
const token = String(headerToken || '');
if (!token) {
return null;
}
if (token.includes('thang') || token.includes('ky')) return 'requestMonth';
if (token.includes('model') || token.includes('quycach')) return 'model';
if (token.includes('tondau')) return 'openingBalance';
if (token.includes('nhaptrongky') || token.includes('nhaptrongki')) return 'importInPeriod';
if (token.includes('xuattrongky') || token.includes('xuattrongki')) return 'exportInPeriod';
if (token.includes('toncuoi')) return 'endingBalance';
if (token.includes('donvi') || token.includes('dvt') || token === 'unit') return 'unit';
if (token.includes('lydoxuat') || token.includes('lidoxuat') || token.includes('ghichu') || token === 'notes') return 'exportReason';
const hasTen = token.includes('ten');
const hasMa = token.includes('ma');
const hasConsumableLike = token.includes('linhkien') || token.includes('vattu') || token.includes('sanpham') || token.includes('sp');
if (hasTen && hasConsumableLike) return 'consumableName';
if (hasMa && hasConsumableLike) return 'consumableCode';
return null;
}
function resolveConsumableImportFieldByHeader(headerCell) {
const token = normalizeImportToken(headerCell);
if (!token) {
return null;
}
let bestField = null;
let bestScore = 0;
for (const [field, aliases] of Object.entries(CONSUMABLE_IMPORT_ALIASES)) {
for (const alias of aliases) {
const aliasToken = normalizeImportToken(alias);
if (!aliasToken) {
continue;
}
let score = 0;
if (token === aliasToken) {
score = 5;
} else if (token.includes(aliasToken) || aliasToken.includes(token)) {
score = 3;
}
if (score > bestScore) {
bestScore = score;
bestField = field;
}
}
}
return bestField || inferConsumableFieldFromHeaderToken(token);
}
function buildConsumableImportFieldMapFromHeaderRow(headerRow) {
const row = Array.isArray(headerRow) ? headerRow : [];
const fieldMap = {};
for (let index = 0; index < row.length; index += 1) {
const field = resolveConsumableImportFieldByHeader(row[index]);
if (field && fieldMap[field] === undefined) {
fieldMap[field] = index;
}
}
return fieldMap;
}
function scoreConsumableImportFieldMap(fieldMap = {}) {
let score = Object.keys(fieldMap).length;
if (fieldMap.consumableName !== undefined) score += 6;
if (fieldMap.model !== undefined) score += 3;
if (fieldMap.consumableCode !== undefined) score += 3;
if (fieldMap.requestMonth !== undefined) score += 2;
if (fieldMap.openingBalance !== undefined) score += 2;
if (fieldMap.importInPeriod !== undefined) score += 2;
if (fieldMap.exportInPeriod !== undefined) score += 2;
if (fieldMap.endingBalance !== undefined) score += 2;
if (fieldMap.exportReason !== undefined) score += 1;
return score;
}
function generateImportConsumableCodeFromRow(mapped, rowNumber = 0) {
const fromModel = sanitizeAssetCodeToken(mapped.model);
const fromName = sanitizeAssetCodeToken(mapped.consumableName);
const base = (fromModel || fromName || 'VTTH').slice(0, 42);
const sttNumber = parseAssetImportSttNumber(mapped?.sourceStt);
const suffixSeed = sttNumber || rowNumber || 0;
const suffix = String(suffixSeed).padStart(4, '0');
return `VTTH-${base}-${suffix}`;
}
function generateManualConsumableCode(payload = {}) {
const fromModel = sanitizeAssetCodeToken(payload.model);
const fromName = sanitizeAssetCodeToken(payload.consumableName);
const base = (fromModel || fromName || 'VTTH').slice(0, 32);
const timestamp = formatAppTimestampForCode(new Date(), true);
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `VTTH-${base}-${timestamp}${randomSuffix}`;
}
async function generateUniqueManualConsumableCode(payload = {}, maxAttempts = 8) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = generateManualConsumableCode(payload);
const existed = await pool.request()
.input('consumableCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 ConsumableId
FROM ConsumableInventory
WHERE ConsumableCode = @consumableCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
}
throw new Error('Cannot generate unique consumable code');
}
function finalizeImportedConsumablePayload(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.consumableName) {
result.consumableName = String(result.model || result.consumableCode || '').trim();
}
if (!result.consumableCode && result.consumableName) {
result.consumableCode = generateImportConsumableCodeFromRow(result, rowNumber);
}
return result;
}
function parseConsumableImportRowsByHeaderMap(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 120);
let bestHeaderRowIndex = -1;
let bestFieldMap = {};
let bestScore = 0;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const headerRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
if (!headerRow.some(cell => String(cell ?? '').trim() !== '')) {
continue;
}
const candidateMap = buildConsumableImportFieldMapFromHeaderRow(headerRow);
const score = scoreConsumableImportFieldMap(candidateMap);
if (score > bestScore) {
bestScore = score;
bestHeaderRowIndex = rowIndex;
bestFieldMap = candidateMap;
}
}
if (bestHeaderRowIndex < 0 || bestScore < 6) {
return [];
}
const pick = (row, index) => {
if (!Array.isArray(row) || index === undefined || index < 0) {
return '';
}
return row[index] ?? '';
};
return rows
.slice(bestHeaderRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const sttValue = parseAssetImportSttNumber(pick(row, bestFieldMap.stt));
if (bestFieldMap.stt !== undefined && sttValue === null) {
return null;
}
const mapped = {
sourceStt: sttValue,
requestMonth: String(pick(row, bestFieldMap.requestMonth)).trim(),
consumableCode: String(pick(row, bestFieldMap.consumableCode)).trim(),
consumableName: String(pick(row, bestFieldMap.consumableName)).trim(),
model: String(pick(row, bestFieldMap.model)).trim(),
unit: String(pick(row, bestFieldMap.unit)).trim(),
openingBalance: parseAssetImportNumericValue(pick(row, bestFieldMap.openingBalance), 0),
importInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.importInPeriod), 0),
exportInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.exportInPeriod), 0),
endingBalance: parseAssetImportNumericValue(pick(row, bestFieldMap.endingBalance), 0),
exportReason: String(pick(row, bestFieldMap.exportReason)).trim()
};
const hasCoreValue = [mapped.consumableCode, mapped.consumableName, mapped.model, mapped.exportReason]
.some(value => String(value || '').trim() !== '');
if (!hasCoreValue) {
return null;
}
return finalizeImportedConsumablePayload(mapped, bestHeaderRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !isHeaderLikeConsumableImportRow(row))
.filter(row => isMeaningfulImportedConsumableRow(row));
}
function parseConsumableImportRowsLoose(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const sttCol = detectLikelySttColumn(rows);
if (sttCol < 0) {
return [];
}
return rows
.filter(row => Array.isArray(row) && parseAssetImportSttNumber(row[sttCol]) !== null)
.map((row, rowOffset) => {
const mapped = {
sourceStt: parseAssetImportSttNumber(row[sttCol]),
requestMonth: String(row[sttCol + 1] ?? '').trim(),
consumableCode: String(row[sttCol + 2] ?? '').trim(),
consumableName: String(row[sttCol + 3] ?? '').trim(),
model: String(row[sttCol + 4] ?? '').trim(),
unit: String(row[sttCol + 5] ?? '').trim(),
openingBalance: parseAssetImportNumericValue(row[sttCol + 6] ?? '', 0),
importInPeriod: parseAssetImportNumericValue(row[sttCol + 7] ?? '', 0),
exportInPeriod: parseAssetImportNumericValue(row[sttCol + 8] ?? '', 0),
endingBalance: parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0),
exportReason: String(row[sttCol + 10] ?? '').trim()
};
return finalizeImportedConsumablePayload(mapped, rowOffset + 2);
})
.filter(row => !isHeaderLikeConsumableImportRow(row))
.filter(row => isMeaningfulImportedConsumableRow(row));
}
function parseConsumableImportRows(matrixRows) {
const headerRows = parseConsumableImportRowsByHeaderMap(matrixRows);
if (headerRows.length > 0) {
return headerRows;
}
return parseConsumableImportRowsLoose(matrixRows);
}
function scoreConsumableImportSheet(sheetName = '', matrixRows = []) {
const sheetToken = normalizeImportToken(sheetName);
const titleToken = normalizeImportToken(
(Array.isArray(matrixRows) ? matrixRows.slice(0, 8) : [])
.flat()
.join(' ')
);
const token = `${sheetToken} ${titleToken}`;
let score = 0;
if (token.includes('vattutieuhao') || token.includes('vtth')) score += 220;
if (token.includes('baocaoxuatnhaptonkho')) score += 100;
if (token.includes('xuatnhapton')) score += 80;
if (sheetToken.includes('2026') || sheetToken.includes('2025')) score += 20;
if (token.includes('kho')) score += 20;
return score;
}
function parseConsumableImportRowsFromWorkbook(workbook) {
const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : [];
let bestRows = [];
let bestSheetName = '';
let bestNonEmptyRows = 0;
let bestSheetPriority = 0;
const diagnostics = [];
for (const sheetName of sheetNames) {
const sheet = workbook.Sheets?.[sheetName];
if (!sheet) {
continue;
}
const matrixRows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false
});
const parsedRows = parseConsumableImportRows(matrixRows);
const nonEmptyRows = countNonEmptyMatrixRows(matrixRows);
const sheetPriority = scoreConsumableImportSheet(sheetName, matrixRows);
diagnostics.push({
sheetName,
parsedRows: parsedRows.length,
nonEmptyRows,
sheetPriority
});
if (
(sheetPriority > bestSheetPriority && parsedRows.length > 0)
|| (sheetPriority === bestSheetPriority && parsedRows.length > bestRows.length)
|| (sheetPriority === bestSheetPriority && parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)
) {
bestRows = parsedRows;
bestSheetName = sheetName;
bestNonEmptyRows = nonEmptyRows;
bestSheetPriority = sheetPriority;
}
}
return {
rows: bestRows,
sheetName: bestSheetName,
diagnostics
};
}
// Security and request middleware
app.disable('x-powered-by');
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || (IS_PRODUCTION ? 1 : 0)));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
formAction: ["'self'"],
frameAncestors: ["'none'"],
imgSrc: ["'self'", 'data:', 'blob:'],
objectSrc: ["'none'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://cdn.sheetjs.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdn.jsdelivr.net'],
workerSrc: ["'self'", 'blob:'],
...(IS_PRODUCTION ? { upgradeInsecureRequests: [] } : {})
}
},
crossOriginEmbedderPolicy: false,
strictTransportSecurity: IS_PRODUCTION
? { maxAge: 31536000, includeSubDomains: true }
: false
}));
const configuredOrigins = String(process.env.CORS_ALLOWED_ORIGINS || '')
.split(',')
.map(origin => origin.trim().replace(/\/+$/, ''))
.filter(Boolean);
const allowedOrigins = new Set(configuredOrigins);
try {
allowedOrigins.add(new URL(APP_BASE_URL).origin);
} catch (err) {
console.warn('[SECURITY] APP_BASE_URL is not a valid absolute URL; configure CORS_ALLOWED_ORIGINS explicitly.');
}
function requestOriginIsAllowed(req) {
const origin = String(req.headers.origin || '').replace(/\/+$/, '');
if (!origin) {
return true;
}
const requestOrigin = `${req.protocol}://${req.get('host')}`.replace(/\/+$/, '');
return origin === requestOrigin || allowedOrigins.has(origin);
}
app.use(cors({
origin(origin, callback) {
if (!origin || allowedOrigins.has(String(origin).replace(/\/+$/, ''))) {
return callback(null, true);
}
return callback(null, false);
},
credentials: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Requested-With']
}));
app.use((req, res, next) => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) && !requestOriginIsAllowed(req)) {
return res.status(403).json({ success: false, message: 'Request origin is not allowed' });
}
next();
});
app.use(express.json({ limit: '1mb', strict: true }));
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
const loginRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { success: false, message: 'Too many sign-in attempts. Please try again later.' }
});
const accountRecoveryRateLimit = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { success: false, message: 'Too many requests. Please try again later.' }
});
const credentialRevealRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 60,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { success: false, message: 'Too many credential reveal requests. Please try again later.' }
});
const allowedSpreadsheetExtensions = new Set(['.xls', '.xlsx']);
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024,
files: 1,
fields: 10,
parts: 12
},
fileFilter(req, file, callback) {
const extension = require('path').extname(String(file.originalname || '')).toLowerCase();
if (!allowedSpreadsheetExtensions.has(extension)) {
return callback(new Error('Only .xls and .xlsx files are allowed'));
}
callback(null, true);
}
});
// Serve static files from /public
const path = require('path');
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
// Root route
app.get('/', (req, res) => {
res.sendFile(path.join(publicDir, 'pages', 'login.html'));
});
// SQL Server Configuration
const sqlConfig = {
server: DB_SERVER,
authentication: {
type: 'default',
options: {
userName: DB_USER,
password: DB_PASSWORD
}
},
options: {
database: DB_NAME,
trustServerCertificate: DB_TRUST_CERTIFICATE,
enableKeepAlive: true,
connectTimeout: DB_CONNECT_TIMEOUT,
encrypt: DB_ENCRYPT,
useUTC: false
}
};
// Initialize Database Pool
let pool;
function parseRequestCookies(req) {
const cookies = {};
const header = String(req.headers.cookie || '');
for (const part of header.split(';')) {
const separatorIndex = part.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const name = part.slice(0, separatorIndex).trim();
const rawValue = part.slice(separatorIndex + 1).trim();
try {
cookies[name] = decodeURIComponent(rawValue);
} catch (err) {
cookies[name] = rawValue;
}
}
return cookies;
}
function hashSessionToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function getSessionLifetimeMs(remember) {
return remember
? REMEMBER_SESSION_TTL_DAYS * 24 * 60 * 60 * 1000
: SESSION_TTL_HOURS * 60 * 60 * 1000;
}
function setSessionCookie(res, token, expiresAt) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: 'strict',
path: '/',
expires: expiresAt
});
}
function clearSessionCookie(res) {
res.clearCookie(SESSION_COOKIE_NAME, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: 'strict',
path: '/'
});
}
function toSafeUser(user) {
if (!user) {
return null;
}
const role = user.Role || user.RoleName || 'guest';
return {
UserId: user.UserId,
Username: user.Username,
Email: user.Email,
FullName: user.FullName,
Role: role,
role,
RoleId: user.RoleId,
Status: user.Status,
EmailVerified: Boolean(user.EmailVerified)
};
}
async function createAuthSession(userId, req, res, remember = false) {
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + getSessionLifetimeMs(remember));
const userAgent = String(req.get('user-agent') || '').slice(0, 500);
const ipAddress = String(req.ip || req.socket?.remoteAddress || '').slice(0, 64);
await pool.request()
.input('tokenHash', sql.Char(64), tokenHash)
.input('userId', sql.Int, userId)
.input('expiresAt', sql.DateTime2, expiresAt)
.input('userAgent', sql.NVarChar(500), userAgent || null)
.input('ipAddress', sql.NVarChar(64), ipAddress || null)
.query(`
DELETE FROM AuthSessions WHERE ExpiresAt <= SYSUTCDATETIME();
INSERT INTO AuthSessions (TokenHash, UserId, ExpiresAt, UserAgent, IpAddress)
VALUES (@tokenHash, @userId, @expiresAt, @userAgent, @ipAddress);
`);
setSessionCookie(res, token, expiresAt);
}
async function destroyAuthSession(req, res) {
const token = parseRequestCookies(req)[SESSION_COOKIE_NAME];
if (token) {
await pool.request()
.input('tokenHash', sql.Char(64), hashSessionToken(token))
.query('DELETE FROM AuthSessions WHERE TokenHash = @tokenHash');
}
clearSessionCookie(res);
}
async function invalidateUserSessions(userId, exceptSessionId = null) {
if (!userId) {
return;
}
const request = pool.request().input('userId', sql.Int, userId);
if (exceptSessionId) {
request.input('sessionId', sql.BigInt, exceptSessionId);
await request.query('DELETE FROM AuthSessions WHERE UserId = @userId AND SessionId <> @sessionId');
return;
}
await request.query('DELETE FROM AuthSessions WHERE UserId = @userId');
}
async function requireAuth(req, res, next) {
try {
const token = parseRequestCookies(req)[SESSION_COOKIE_NAME];
if (!token) {
return res.status(401).json({ success: false, message: 'Authentication required' });
}
const result = await pool.request()
.input('tokenHash', sql.Char(64), hashSessionToken(token))
.query(`
SELECT TOP 1
s.SessionId,
u.UserId,
u.Username,
u.Email,
u.FullName,
COALESCE(r.RoleName, u.Role) AS Role,
u.RoleId,
u.Status,
u.EmailVerified
FROM AuthSessions s
INNER JOIN Users u ON u.UserId = s.UserId
LEFT JOIN Roles r ON r.RoleId = u.RoleId
WHERE s.TokenHash = @tokenHash
AND s.ExpiresAt > SYSUTCDATETIME()
AND u.IsActive = 1
AND u.EmailVerified = 1;
`);
if (result.recordset.length === 0) {
clearSessionCookie(res);
return res.status(401).json({ success: false, message: 'Session is invalid or expired' });
}
const authenticated = result.recordset[0];
req.authSessionId = authenticated.SessionId;
req.user = toSafeUser(authenticated);
next();
} catch (err) {
console.error('Authentication middleware error:', err.message);
res.status(500).json({ success: false, message: 'Unable to validate session' });
}
}
async function initializeDatabase() {
try {
pool = new sql.ConnectionPool(sqlConfig);
await pool.connect();
console.log('[OK] Connected to SQL Server');
// Check and create database if not exists
const masterConnection = new sql.ConnectionPool({
server: DB_SERVER,
authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } },
options: {
connectTimeout: DB_CONNECT_TIMEOUT,
database: 'master',
trustServerCertificate: DB_TRUST_CERTIFICATE,
encrypt: DB_ENCRYPT,
useUTC: false
}
});
await masterConnection.connect();
const createDbResult = await masterConnection.request()
.query(`IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'AccManager')
BEGIN
CREATE DATABASE AccManager;
END`);
await masterConnection.close();
// Now create tables in AccManager
await createTables();
await migrateLegacyPasswords();
await migrateStoredAccountPasswords();
console.log('[OK] Database and tables created');
} catch (err) {
console.error('Database connection failed:', err);
process.exit(1);
}
}
async function migrateLegacyPasswords() {
try {
const usersResult = await pool.request()
.query('SELECT UserId, Password FROM Users WHERE Password IS NOT NULL');
let migratedCount = 0;
for (const row of usersResult.recordset) {
const rawPassword = String(row.Password || '');
if (!isBcryptHash(rawPassword)) {
await pool.request()
.input('userId', sql.Int, row.UserId)
.input('password', sql.NVarChar, await hashPassword(rawPassword))
.query('UPDATE Users SET Password = @password WHERE UserId = @userId');
migratedCount += 1;
}
}
// Login passwords must never be recoverable. Retain the legacy column only
// for backward-compatible schema upgrades, but permanently erase its data.
await pool.request().query('UPDATE Users SET ViewPassword = NULL WHERE ViewPassword IS NOT NULL');
if (migratedCount > 0) {
console.log(`[OK] Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
}
} catch (err) {
console.error('Password migration error:', err.message);
}
}
async function migrateStoredAccountPasswords() {
try {
const accountsResult = await pool.request()
.query('SELECT AccountId, AccountPassword FROM Accounts WHERE AccountPassword IS NOT NULL AND AccountPassword <> N\'\'');
let migratedCount = 0;
for (const row of accountsResult.recordset) {
const currentValue = String(row.AccountPassword || '');
if (isEncryptedSensitiveValue(currentValue)) {
continue;
}
await pool.request()
.input('accountId', sql.Int, row.AccountId)
.input('accountPassword', sql.NVarChar(2048), encryptSensitiveValue(currentValue))
.query('UPDATE Accounts SET AccountPassword = @accountPassword WHERE AccountId = @accountId');
migratedCount += 1;
}
if (migratedCount > 0) {
console.log(`[OK] Encrypted ${migratedCount} stored application password(s)`);
}
} catch (err) {
console.error('Stored account password migration error:', err.message);
throw err;
}
}
async function ensureAppTimeDefaultConstraints() {
await pool.request().query(`
DECLARE @schemaName SYSNAME = N'dbo';
DECLARE @defaults TABLE (
TableName SYSNAME NOT NULL,
ColumnName SYSNAME NOT NULL,
ConstraintName SYSNAME NOT NULL,
Definition NVARCHAR(MAX) NOT NULL
);
INSERT INTO @defaults (TableName, ColumnName, ConstraintName, Definition)
VALUES
(N'Users', N'CreatedDate', N'DF_Users_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'Applications', N'CreatedDate', N'DF_Applications_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'Applications', N'UpdatedDate', N'DF_Applications_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'Accounts', N'CreatedDate', N'DF_Accounts_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'Accounts', N'UpdatedDate', N'DF_Accounts_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetInventory', N'CreatedDate', N'DF_AssetInventory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetInventory', N'UpdatedDate', N'DF_AssetInventory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'ConsumableInventory', N'CreatedDate', N'DF_ConsumableInventory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'ConsumableInventory', N'UpdatedDate', N'DF_ConsumableInventory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDepartments', N'CreatedDate', N'DF_AssetDepartments_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDepartments', N'UpdatedDate', N'DF_AssetDepartments_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetProjects', N'CreatedDate', N'DF_AssetProjects_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetProjects', N'UpdatedDate', N'DF_AssetProjects_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetBorrowRequests', N'BorrowDate', N'DF_AssetBorrowRequests_BorrowDate', N'(CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE))'),
(N'AssetBorrowRequests', N'CreatedDate', N'DF_AssetBorrowRequests_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetBorrowRequests', N'UpdatedDate', N'DF_AssetBorrowRequests_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetBorrowRequestLinks', N'CreatedDate', N'DF_AssetBorrowRequestLinks_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetExportHistory', N'ExportedDate', N'DF_AssetExportHistory_ExportedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetExportHistory', N'CreatedDate', N'DF_AssetExportHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetExportHistory', N'UpdatedDate', N'DF_AssetExportHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'ConsumableExportHistory', N'ExportedDate', N'DF_ConsumableExportHistory_ExportedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'ConsumableExportHistory', N'CreatedDate', N'DF_ConsumableExportHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'ConsumableExportHistory', N'UpdatedDate', N'DF_ConsumableExportHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'ActionDate', N'DF_AssetDamageDisposalHistory_ActionDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'CreatedDate', N'DF_AssetDamageDisposalHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'UpdatedDate', N'DF_AssetDamageDisposalHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AuditLog', N'Timestamp', N'DF_AuditLog_Timestamp', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))');
DECLARE @tableName SYSNAME;
DECLARE @columnName SYSNAME;
DECLARE @constraintName SYSNAME;
DECLARE @definition NVARCHAR(MAX);
DECLARE @existingName SYSNAME;
DECLARE @objectId INT;
DECLARE @sql NVARCHAR(MAX);
DECLARE default_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT TableName, ColumnName, ConstraintName, Definition
FROM @defaults;
OPEN default_cursor;
FETCH NEXT FROM default_cursor INTO @tableName, @columnName, @constraintName, @definition;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @objectId = OBJECT_ID(QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName), N'U');
IF @objectId IS NOT NULL
AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = @objectId AND name = @columnName)
BEGIN
SET @existingName = NULL;
SELECT @existingName = dc.name
FROM sys.default_constraints dc
INNER JOIN sys.columns c
ON c.object_id = dc.parent_object_id
AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = @objectId
AND c.name = @columnName;
IF @existingName IS NOT NULL
BEGIN
SET @sql = N'ALTER TABLE '
+ QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName)
+ N' DROP CONSTRAINT ' + QUOTENAME(@existingName);
EXEC sp_executesql @sql;
END
SET @sql = N'ALTER TABLE '
+ QUOTENAME(@schemaName) + N'.' + QUOTENAME(@tableName)
+ N' ADD CONSTRAINT ' + QUOTENAME(@constraintName)
+ N' DEFAULT ' + @definition
+ N' FOR ' + QUOTENAME(@columnName);
EXEC sp_executesql @sql;
END
FETCH NEXT FROM default_cursor INTO @tableName, @columnName, @constraintName, @definition;
END
CLOSE default_cursor;
DEALLOCATE default_cursor;
`);
}
async function createTables() {
const queries = [
// Users Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
BEGIN
CREATE TABLE Users (
UserId INT PRIMARY KEY IDENTITY(1,1),
Username NVARCHAR(50) UNIQUE NOT NULL,
Password NVARCHAR(255) NOT NULL,
Email NVARCHAR(100),
FullName NVARCHAR(100),
Role NVARCHAR(50) NOT NULL,
Status NVARCHAR(20) DEFAULT 'Active',
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
LastLogin DATETIME,
IsActive BIT DEFAULT 1
)
END`,
// Server-side authentication sessions
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuthSessions')
BEGIN
CREATE TABLE AuthSessions (
SessionId BIGINT PRIMARY KEY IDENTITY(1,1),
TokenHash CHAR(64) UNIQUE NOT NULL,
UserId INT NOT NULL,
ExpiresAt DATETIME2 NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UserAgent NVARCHAR(500),
IpAddress NVARCHAR(64),
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE
);
CREATE INDEX IX_AuthSessions_UserId ON AuthSessions(UserId);
CREATE INDEX IX_AuthSessions_ExpiresAt ON AuthSessions(ExpiresAt);
END`,
// Applications Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Applications')
BEGIN
CREATE TABLE Applications (
AppId INT PRIMARY KEY IDENTITY(1,1),
Name NVARCHAR(100) NOT NULL,
Type NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'online',
Icon NVARCHAR(50),
Description NVARCHAR(500),
Url NVARCHAR(255),
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
)
END`,
// Accounts Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Accounts')
BEGIN
CREATE TABLE Accounts (
AccountId INT PRIMARY KEY IDENTITY(1,1),
UserId INT NOT NULL,
AppId INT NOT NULL,
AccountUsername NVARCHAR(100),
AccountPassword NVARCHAR(2048),
Email NVARCHAR(100),
AccessLevel NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'Active',
Notes NVARCHAR(MAX),
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE,
FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE
)
END`,
// Asset Inventory Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
BEGIN
CREATE TABLE AssetInventory (
AssetId INT PRIMARY KEY IDENTITY(1,1),
AssetCode NVARCHAR(100) NOT NULL UNIQUE,
AssetName NVARCHAR(255) NOT NULL,
Model NVARCHAR(255),
SerialNumber NVARCHAR(100),
Quantity INT NOT NULL DEFAULT 0,
ImportInPeriod INT NOT NULL DEFAULT 0,
ExportInPeriod INT NOT NULL DEFAULT 0,
EndingBalance INT NOT NULL DEFAULT 0,
NewQuantity INT NOT NULL DEFAULT 0,
UsedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50),
Department NVARCHAR(100),
Project NVARCHAR(150),
Location NVARCHAR(150),
Custodian NVARCHAR(100),
Borrower NVARCHAR(255),
ExportedBy NVARCHAR(100),
PurchaseDate DATE NULL,
PurchasePrice DECIMAL(18,2) NULL,
Status NVARCHAR(30) NOT NULL DEFAULT 'in_use',
Notes NVARCHAR(MAX),
CreatedBy INT NULL,
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Consumable Inventory Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableInventory')
BEGIN
CREATE TABLE ConsumableInventory (
ConsumableId INT PRIMARY KEY IDENTITY(1,1),
RequestMonth NVARCHAR(50) NULL,
ConsumableCode NVARCHAR(100) NOT NULL UNIQUE,
ConsumableName NVARCHAR(255) NOT NULL,
Model NVARCHAR(255) NULL,
Unit NVARCHAR(50) NULL,
OpeningBalance INT NOT NULL DEFAULT 0,
ImportInPeriod INT NOT NULL DEFAULT 0,
ExportInPeriod INT NOT NULL DEFAULT 0,
EndingBalance INT NOT NULL DEFAULT 0,
ExportReason NVARCHAR(1000) NULL,
CreatedBy INT NULL,
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Consumable Export History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableExportHistory')
BEGIN
CREATE TABLE ConsumableExportHistory (
ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
ConsumableId INT NOT NULL,
ConsumableCode NVARCHAR(100) NOT NULL,
ConsumableName NVARCHAR(255) NOT NULL,
Unit NVARCHAR(50) NULL,
ExportQuantity INT NOT NULL DEFAULT 1,
RecipientUserId INT NULL,
RecipientName NVARCHAR(100) NULL,
ProjectName NVARCHAR(150) NULL,
ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
CreatedBy INT NULL,
ExportedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Consumable Return History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableReturnHistory')
BEGIN
CREATE TABLE ConsumableReturnHistory (
ReturnHistoryId INT PRIMARY KEY IDENTITY(1,1),
ExportHistoryId INT NOT NULL,
ConsumableId INT NOT NULL,
ReturnQuantity INT NOT NULL DEFAULT 1,
ReturnedByName NVARCHAR(100) NOT NULL,
ReturnNote NVARCHAR(1000) NULL,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
CreatedBy INT NULL,
ReturnedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ExportHistoryId) REFERENCES ConsumableExportHistory(ExportHistoryId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Consumable Borrow Requests Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableBorrowRequests')
BEGIN
CREATE TABLE ConsumableBorrowRequests (
BorrowRequestId INT PRIMARY KEY IDENTITY(1,1),
ConsumableId INT NOT NULL,
RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow',
BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50) NULL,
BorrowDate DATE NOT NULL DEFAULT (CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE)),
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
RequestNote NVARCHAR(500) NULL,
RejectReason NVARCHAR(1000) NULL,
ExportHistoryId INT NULL,
CreatedBy INT NULL,
ProcessedBy INT NULL,
ProcessedByName NVARCHAR(100) NULL,
ProcessedDate DATETIME NULL,
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Asset Departments Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
)
END`,
// Asset Projects Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetProjects')
BEGIN
CREATE TABLE AssetProjects (
ProjectId INT PRIMARY KEY IDENTITY(1,1),
ProjectName NVARCHAR(150) NOT NULL,
CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
)
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,
ReturnedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT (CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) 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 (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
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 (DATEADD(HOUR, 7, SYSUTCDATETIME())),
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
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 (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE
)
END`,
// Asset Damage/Disposal History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory')
BEGIN
CREATE TABLE AssetDamageDisposalHistory (
DamageHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ActionType NVARCHAR(20) NOT NULL,
ActionLabel NVARCHAR(50) NOT NULL,
ActionQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50) NULL,
PreviousQuantity INT NOT NULL DEFAULT 0,
NextQuantity INT NOT NULL DEFAULT 0,
PreviousImportInPeriod INT NOT NULL DEFAULT 0,
NextImportInPeriod INT NOT NULL DEFAULT 0,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
PreviousNewQuantity INT NOT NULL DEFAULT 0,
NextNewQuantity INT NOT NULL DEFAULT 0,
PreviousUsedQuantity INT NOT NULL DEFAULT 0,
NextUsedQuantity INT NOT NULL DEFAULT 0,
ActionNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
CreatedByName NVARCHAR(100) NULL,
ActionDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
CREATE TABLE AuditLog (
LogId INT PRIMARY KEY IDENTITY(1,1),
UserId INT,
Action NVARCHAR(50),
TableName NVARCHAR(50),
RecordId INT,
OldValue NVARCHAR(MAX),
NewValue NVARCHAR(MAX),
Timestamp DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (UserId) REFERENCES Users(UserId)
)
END`
];
for (let query of queries) {
try {
await pool.request().query(query);
} catch (err) {
console.error('Table creation error:', err.message);
}
}
// Ensure AssetInventory indexes exist for lookup/filter performance
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`);
} catch (err) {
console.error('AssetInventory index creation error:', err.message);
}
// Ensure ConsumableInventory indexes exist for lookup/filter performance
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_ConsumableCode') CREATE INDEX IX_ConsumableInventory_ConsumableCode ON ConsumableInventory(ConsumableCode);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_RequestMonth') CREATE INDEX IX_ConsumableInventory_RequestMonth ON ConsumableInventory(RequestMonth);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_EndingBalance') CREATE INDEX IX_ConsumableInventory_EndingBalance ON ConsumableInventory(EndingBalance);`);
} catch (err) {
console.error('ConsumableInventory index creation error:', err.message);
}
// Ensure AssetDepartments indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`);
} catch (err) {
console.error('AssetDepartments index creation error:', err.message);
}
// Ensure AssetProjects indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetProjects_ProjectName') CREATE UNIQUE INDEX UX_AssetProjects_ProjectName ON AssetProjects(ProjectName);`);
} catch (err) {
console.error('AssetProjects 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);`);
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);
}
// 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 ConsumableExportHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ConsumableId') CREATE INDEX IX_ConsumableExportHistory_ConsumableId ON ConsumableExportHistory(ConsumableId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ExportedDate') CREATE INDEX IX_ConsumableExportHistory_ExportedDate ON ConsumableExportHistory(ExportedDate DESC);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory', 'RecipientUserId') IS NOT NULL AND NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_RecipientUserId') CREATE INDEX IX_ConsumableExportHistory_RecipientUserId ON ConsumableExportHistory(RecipientUserId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableReturnHistory_ExportHistoryId') CREATE INDEX IX_ConsumableReturnHistory_ExportHistoryId ON ConsumableReturnHistory(ExportHistoryId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableReturnHistory_ConsumableId') CREATE INDEX IX_ConsumableReturnHistory_ConsumableId ON ConsumableReturnHistory(ConsumableId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableReturnHistory_ReturnedDate') CREATE INDEX IX_ConsumableReturnHistory_ReturnedDate ON ConsumableReturnHistory(ReturnedDate DESC);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_ConsumableId') CREATE INDEX IX_ConsumableBorrowRequests_ConsumableId ON ConsumableBorrowRequests(ConsumableId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_CreatedBy') CREATE INDEX IX_ConsumableBorrowRequests_CreatedBy ON ConsumableBorrowRequests(CreatedBy);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_RequestStatus') CREATE INDEX IX_ConsumableBorrowRequests_RequestStatus ON ConsumableBorrowRequests(RequestStatus);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_CreatedDate') CREATE INDEX IX_ConsumableBorrowRequests_CreatedDate ON ConsumableBorrowRequests(CreatedDate DESC);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableBorrowRequests', 'RequestType') IS NOT NULL AND NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_RequestType') CREATE INDEX IX_ConsumableBorrowRequests_RequestType ON ConsumableBorrowRequests(RequestType);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_ExportHistoryId') CREATE INDEX IX_ConsumableBorrowRequests_ExportHistoryId ON ConsumableBorrowRequests(ExportHistoryId);`);
} catch (err) {
console.error('Consumable export/return history index creation error:', err.message);
}
// Ensure AssetDamageDisposalHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);`);
} catch (err) {
console.error('AssetDamageDisposalHistory index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
SELECT 1
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.AssetInventory')
AND name = 'Model'
AND max_length < 510
)
ALTER TABLE AssetInventory ALTER COLUMN Model NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ImportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ImportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ExportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','EndingBalance') IS NULL ALTER TABLE AssetInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_AssetInventory_EndingBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','NewQuantity') IS NULL ALTER TABLE AssetInventory ADD NewQuantity INT NOT NULL CONSTRAINT DF_AssetInventory_NewQuantity DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','UsedQuantity') IS NULL ALTER TABLE AssetInventory ADD UsedQuantity INT NOT NULL CONSTRAINT DF_AssetInventory_UsedQuantity DEFAULT(0);`);
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.ConsumableInventory','RequestMonth') IS NULL ALTER TABLE ConsumableInventory ADD RequestMonth NVARCHAR(50) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','Model') IS NULL ALTER TABLE ConsumableInventory ADD Model NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','Unit') IS NULL ALTER TABLE ConsumableInventory ADD Unit NVARCHAR(50) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','OpeningBalance') IS NULL ALTER TABLE ConsumableInventory ADD OpeningBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_OpeningBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ImportInPeriod') IS NULL ALTER TABLE ConsumableInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ImportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ExportInPeriod') IS NULL ALTER TABLE ConsumableInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ExportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','EndingBalance') IS NULL ALTER TABLE ConsumableInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_EndingBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ExportReason') IS NULL ALTER TABLE ConsumableInventory ADD ExportReason NVARCHAR(1000) NULL;`);
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableExportHistory')
BEGIN
CREATE TABLE ConsumableExportHistory (
ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
ConsumableId INT NOT NULL,
ConsumableCode NVARCHAR(100) NOT NULL,
ConsumableName NVARCHAR(255) NOT NULL,
Unit NVARCHAR(50) NULL,
ExportQuantity INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportQuantity DEFAULT(1),
RecipientUserId INT NULL,
RecipientName NVARCHAR(100) NULL,
ProjectName NVARCHAR(150) NULL,
ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL,
PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousExportInPeriod DEFAULT(0),
NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextExportInPeriod DEFAULT(0),
PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousEndingBalance DEFAULT(0),
NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextEndingBalance DEFAULT(0),
CreatedBy INT NULL,
ExportedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
END
`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ConsumableCode') IS NULL ALTER TABLE ConsumableExportHistory ADD ConsumableCode NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ConsumableName') IS NULL ALTER TABLE ConsumableExportHistory ADD ConsumableName NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','Unit') IS NULL ALTER TABLE ConsumableExportHistory ADD Unit NVARCHAR(50) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportQuantity') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportQuantity DEFAULT(1);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','RecipientUserId') IS NULL ALTER TABLE ConsumableExportHistory ADD RecipientUserId INT NULL;`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_RecipientUserId') CREATE INDEX IX_ConsumableExportHistory_RecipientUserId ON ConsumableExportHistory(RecipientUserId);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','RecipientName') IS NULL ALTER TABLE ConsumableExportHistory ADD RecipientName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','RecipientName') IS NOT NULL ALTER TABLE ConsumableExportHistory ALTER COLUMN RecipientName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ProjectName') IS NULL ALTER TABLE ConsumableExportHistory ADD ProjectName NVARCHAR(150) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportedByName') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportedByName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportNote') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportNote NVARCHAR(1000) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','PreviousExportInPeriod') IS NULL ALTER TABLE ConsumableExportHistory ADD PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousExportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','NextExportInPeriod') IS NULL ALTER TABLE ConsumableExportHistory ADD NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextExportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','PreviousEndingBalance') IS NULL ALTER TABLE ConsumableExportHistory ADD PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousEndingBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','NextEndingBalance') IS NULL ALTER TABLE ConsumableExportHistory ADD NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextEndingBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','CreatedBy') IS NULL ALTER TABLE ConsumableExportHistory ADD CreatedBy INT NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','CreatedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','UpdatedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableReturnHistory')
BEGIN
CREATE TABLE ConsumableReturnHistory (
ReturnHistoryId INT PRIMARY KEY IDENTITY(1,1),
ExportHistoryId INT NOT NULL,
ConsumableId INT NOT NULL,
ReturnQuantity INT NOT NULL CONSTRAINT DF_ConsumableReturnHistory_ReturnQuantity DEFAULT(1),
ReturnedByName NVARCHAR(100) NOT NULL,
ReturnNote NVARCHAR(1000) NULL,
PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableReturnHistory_PreviousExportInPeriod DEFAULT(0),
NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableReturnHistory_NextExportInPeriod DEFAULT(0),
PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableReturnHistory_PreviousEndingBalance DEFAULT(0),
NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableReturnHistory_NextEndingBalance DEFAULT(0),
CreatedBy INT NULL,
ReturnedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableReturnHistory_ReturnedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableReturnHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableReturnHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ExportHistoryId) REFERENCES ConsumableExportHistory(ExportHistoryId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
END
`);
await pool.request().query(`
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableBorrowRequests')
BEGIN
CREATE TABLE ConsumableBorrowRequests (
BorrowRequestId INT PRIMARY KEY IDENTITY(1,1),
ConsumableId INT NOT NULL,
RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_RequestType DEFAULT('borrow'),
BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_BorrowQuantity DEFAULT(1),
Unit NVARCHAR(50) NULL,
BorrowDate DATE NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_BorrowDate DEFAULT(CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE)),
RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_RequestStatus DEFAULT('pending'),
RequestNote NVARCHAR(500) NULL,
RejectReason NVARCHAR(1000) NULL,
ExportHistoryId INT NULL,
CreatedBy INT NULL,
ProcessedBy INT NULL,
ProcessedByName NVARCHAR(100) NULL,
ProcessedDate DATETIME NULL,
CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
END
`);
await pool.request().query(`IF COL_LENGTH('dbo.ConsumableBorrowRequests','RequestType') IS NULL ALTER TABLE ConsumableBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_ConsumableBorrowRequests_RequestType DEFAULT('borrow');`);
await pool.request().query(`UPDATE ConsumableBorrowRequests SET RequestType = 'borrow' WHERE RequestType IS NULL OR LTRIM(RTRIM(RequestType)) = '';`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableBorrowRequests_RequestType') CREATE INDEX IX_ConsumableBorrowRequests_RequestType ON ConsumableBorrowRequests(RequestType);`);
await pool.request().query(`
DECLARE @ConsumableBorrowProcessedByFk NVARCHAR(128);
SELECT TOP 1 @ConsumableBorrowProcessedByFk = fk.name
FROM sys.foreign_keys fk
INNER JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
INNER JOIN sys.columns c
ON c.object_id = fkc.parent_object_id
AND c.column_id = fkc.parent_column_id
WHERE fk.parent_object_id = OBJECT_ID('dbo.ConsumableBorrowRequests')
AND c.name = 'ProcessedBy';
IF @ConsumableBorrowProcessedByFk IS NOT NULL
BEGIN
DECLARE @DropConsumableBorrowProcessedByFkSql NVARCHAR(MAX) =
N'ALTER TABLE ConsumableBorrowRequests DROP CONSTRAINT ' + QUOTENAME(@ConsumableBorrowProcessedByFk);
EXEC sp_executesql @DropConsumableBorrowProcessedByFkSql;
END
`);
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.ConsumableExportHistory')
AND c.name = 'ConsumableId'
)
AND COL_LENGTH('dbo.ConsumableExportHistory', 'ConsumableId') IS NOT NULL
BEGIN
ALTER TABLE ConsumableExportHistory
ADD CONSTRAINT FK_ConsumableExportHistory_ConsumableId
FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE;
END
`);
await pool.request().query(`
UPDATE exports
SET RecipientUserId = matchedUser.UserId
FROM ConsumableExportHistory exports
CROSS APPLY (
SELECT TOP 1 users.UserId
FROM Users users
WHERE NULLIF(LTRIM(RTRIM(exports.ProjectName)), '') IS NULL
AND (
LOWER(LTRIM(RTRIM(ISNULL(users.FullName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
OR LOWER(LTRIM(RTRIM(ISNULL(users.Username, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
)
ORDER BY CASE
WHEN LOWER(LTRIM(RTRIM(ISNULL(users.FullName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, '')))) THEN 0
ELSE 1
END,
users.UserId
) matchedUser
WHERE exports.RecipientUserId IS NULL;
`);
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.ConsumableExportHistory')
AND c.name = 'CreatedBy'
)
AND COL_LENGTH('dbo.ConsumableExportHistory', 'CreatedBy') IS NOT NULL
BEGIN
ALTER TABLE ConsumableExportHistory
ADD CONSTRAINT FK_ConsumableExportHistory_CreatedBy
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
END
`);
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(DATEADD(HOUR, 7, SYSUTCDATETIME()) 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;`);
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 COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
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(DATEADD(HOUR, 7, SYSUTCDATETIME())),
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);`);
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(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
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(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
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(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
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 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 = DATEADD(HOUR, 7, SYSUTCDATETIME())
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;`);
await pool.request().query(`
UPDATE AssetInventory
SET NewQuantity = CASE
WHEN ISNULL(NewQuantity, 0) < 0 THEN 0
ELSE ISNULL(NewQuantity, 0)
END;
`);
await pool.request().query(`
UPDATE AssetInventory
SET NewQuantity = CASE
WHEN (ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) < ISNULL(EndingBalance, 0)
THEN ISNULL(NewQuantity, 0) + (ISNULL(EndingBalance, 0) - (ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)))
WHEN (ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) > ISNULL(EndingBalance, 0)
THEN CASE
WHEN ISNULL(NewQuantity, 0) >= ((ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) - ISNULL(EndingBalance, 0))
THEN ISNULL(NewQuantity, 0) - ((ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) - ISNULL(EndingBalance, 0))
ELSE 0
END
ELSE ISNULL(NewQuantity, 0)
END,
UsedQuantity = CASE
WHEN (ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) > ISNULL(EndingBalance, 0)
AND ISNULL(NewQuantity, 0) < ((ISNULL(NewQuantity, 0) + ISNULL(UsedQuantity, 0)) - ISNULL(EndingBalance, 0))
THEN ISNULL(EndingBalance, 0)
ELSE ISNULL(UsedQuantity, 0)
END;
`);
await pool.request().query(`
UPDATE ai
SET ai.ExportedBy = COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), NULLIF(LTRIM(RTRIM(u.Username)), ''))
FROM AssetInventory ai
LEFT JOIN Users u ON ai.CreatedBy = u.UserId
WHERE ai.ExportedBy IS NULL
`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Category') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Category;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Brand') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Brand;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','WarrantyUntil') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN WarrantyUntil;`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`);
await pool.request().query(`IF COL_LENGTH('dbo.Accounts','AccountPassword') IS NOT NULL ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerified') IS NULL ALTER TABLE Users ADD EmailVerified BIT NOT NULL CONSTRAINT DF_Users_EmailVerified DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifiedAt') IS NULL ALTER TABLE Users ADD EmailVerifiedAt DATETIME NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyToken') IS NULL ALTER TABLE Users ADD EmailVerifyToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyTokenExpires') IS NULL ALTER TABLE Users ADD EmailVerifyTokenExpires DATETIME NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
await pool.request().query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, DATEADD(HOUR, 7, SYSUTCDATETIME())) WHERE LOWER(ISNULL(Role, '')) = 'admin';`);
// Backfill Url to empty string to avoid undefined in responses
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
} catch (err) {
console.error('Column addition error (Applications):', err.message);
}
try {
await ensureAppTimeDefaultConstraints();
} catch (err) {
console.error('Timezone default constraint error:', err.message);
}
// Sync legacy departments from AssetInventory to AssetDepartments
try {
await syncAssetDepartmentsFromInventory();
} catch (err) {
console.error('AssetDepartments sync error:', err.message);
}
// Sync legacy projects from AssetInventory to AssetProjects
try {
await syncAssetProjectsFromInventory();
} catch (err) {
console.error('AssetProjects sync error:', err.message);
}
// Insert initial admin user
try {
const initialAdminPassword = String(process.env.INITIAL_ADMIN_PASSWORD || '');
if (initialAdminPassword) {
if (initialAdminPassword.length < 12) {
throw new Error('INITIAL_ADMIN_PASSWORD must contain at least 12 characters');
}
const result = await pool.request()
.input('username', sql.NVarChar, String(process.env.INITIAL_ADMIN_USERNAME || 'admin').trim())
.input('password', sql.NVarChar, await hashPassword(initialAdminPassword))
.input('email', sql.NVarChar, String(process.env.INITIAL_ADMIN_EMAIL || 'admin@accmanager.local').trim())
.input('fullname', sql.NVarChar, String(process.env.INITIAL_ADMIN_FULLNAME || 'Administrator').trim())
.input('role', sql.NVarChar, 'admin')
.query(`IF NOT EXISTS (SELECT 1 FROM Users WHERE Username = @username)
BEGIN
INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt)
VALUES (@username, @password, @email, @fullname, @role, 1, 1, DATEADD(HOUR, 7, SYSUTCDATETIME()));
SELECT CAST(1 AS BIT) AS Created;
END
ELSE SELECT CAST(0 AS BIT) AS Created;`);
if (result.recordset?.[0]?.Created) {
console.log('[OK] Initial admin user created from environment configuration');
}
} else {
const adminCount = await pool.request()
.query("SELECT COUNT(*) AS AdminCount FROM Users WHERE LOWER(ISNULL(Role, '')) = 'admin' AND IsActive = 1");
if (Number(adminCount.recordset?.[0]?.AdminCount || 0) === 0) {
console.warn('[SECURITY] No active admin exists. Set a strong INITIAL_ADMIN_PASSWORD once to bootstrap the first administrator.');
}
}
} catch (err) {
console.error('Admin user error:', err.message);
if (IS_PRODUCTION) {
throw err;
}
}
// Insert sample applications
try {
await pool.request()
.query(`IF (SELECT COUNT(*) FROM Applications) = 0
BEGIN
INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
VALUES
('AWS', 'Cloud', 'online', 'cloud', 'Amazon Web Services', 'https://aws.amazon.com'),
('GitHub', 'VCS', 'online', 'code', 'GitHub - Version Control', 'https://github.com'),
('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('[OK] Sample applications created');
} catch (err) {
console.error('Applications error:', err.message);
}
}
// ==========================================
// API ROUTES - Authentication
// ==========================================
app.get('/api/auth/config', (req, res) => {
res.json({ success: true, allowSelfRegistration: ALLOW_SELF_REGISTRATION });
});
// Login endpoint
app.post('/api/auth/login', loginRateLimit, async (req, res) => {
try {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
const remember = req.body?.remember === true;
if (!username || !password) {
return res.status(400).json({
success: false,
message: 'Username and password are required'
});
}
if (username.length > 254 || password.length > 256) {
return res.status(400).json({ success: false, message: 'Invalid username or password' });
}
const result = await pool.request()
.input('username', sql.NVarChar, username)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password, EmailVerified
FROM Users
WHERE (Username = @username OR Email = @username)
AND IsActive = 1`);
if (result.recordset.length > 0) {
const dbUser = result.recordset[0];
const isValidPassword = await verifyPassword(password, dbUser.Password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
if (!dbUser.EmailVerified) {
return res.status(403).json({
success: false,
message: 'Please confirm your email before signing in',
requiresEmailVerification: true,
email: dbUser.Email,
username: dbUser.Username
});
}
// Upgrade old plain-text passwords after successful legacy login.
if (!isBcryptHash(dbUser.Password)) {
const upgradedHash = await hashPassword(password);
await pool.request()
.input('userId', sql.Int, dbUser.UserId)
.input('password', sql.NVarChar, upgradedHash)
.query('UPDATE Users SET Password = @password, ViewPassword = NULL WHERE UserId = @userId');
}
const { Password: _, ...safeUser } = dbUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
// Update last login
await pool.request()
.input('userId', sql.Int, user.UserId)
.query('UPDATE Users SET LastLogin = DATEADD(HOUR, 7, SYSUTCDATETIME()) WHERE UserId = @userId');
await destroyAuthSession(req, res);
await createAuthSession(user.UserId, req, res, remember);
res.json({
success: true,
message: 'Login successful',
user: user
});
} else {
// Equalize the expensive bcrypt work for unknown and known accounts.
await hashPassword(password);
res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
} catch (err) {
console.error('Login error:', err.message);
res.status(500).json({ success: false, message: 'Unable to sign in right now' });
}
});
// Public registration endpoint
app.post('/api/auth/register', accountRecoveryRateLimit, async (req, res) => {
try {
if (!ALLOW_SELF_REGISTRATION) {
return res.status(403).json({ success: false, message: 'Self-registration is disabled. Please contact an administrator.' });
}
const { username, password, email, fullname } = req.body;
if (!username || !password || !email) {
return res.status(400).json({ success: false, message: 'Username, password and email are required' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const safeUsername = String(username).trim();
const safePassword = String(password || '');
if (!/^[A-Za-z0-9._-]{3,50}$/.test(safeUsername)) {
return res.status(400).json({ success: false, message: 'Username must be 3-50 characters and contain only letters, numbers, dot, underscore or hyphen' });
}
if (safePassword.length < 12 || safePassword.length > 256) {
return res.status(400).json({ success: false, message: 'Password must be between 12 and 256 characters' });
}
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
// Prevent duplicate usernames/emails
const existing = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('email', sql.NVarChar, normalizedEmail)
.query('SELECT TOP 1 UserId, Username, Email FROM Users WHERE Username = @username OR Email = @email');
if (existing.recordset.length > 0) {
const existed = existing.recordset[0];
const duplicateByEmail = String(existed.Email || '').toLowerCase() === normalizedEmail;
return res.status(409).json({
success: false,
message: duplicateByEmail ? 'Email already exists' : 'Username already exists'
});
}
const hashedPassword = await hashPassword(safePassword);
const { token, tokenHash } = generateEmailVerificationToken();
const safeFullname = fullname && fullname.trim() ? fullname.trim() : safeUsername;
let guestRoleName = 'guest';
let guestRoleId = null;
const hasRoleIdColResult = await pool.request()
.query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId");
const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1;
const hasRolesTableResult = await pool.request()
.query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable");
const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1;
if (hasRolesTable) {
const guestRoleResult = await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest')
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES ('Guest', 'Default role for self-registered users');
END
SELECT TOP 1 RoleId, RoleName
FROM Roles
WHERE LOWER(RoleName) = 'guest';
`);
if (guestRoleResult.recordset.length > 0) {
guestRoleId = guestRoleResult.recordset[0].RoleId;
guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest';
}
}
let result;
if (hasRoleIdColumn && guestRoleId !== null) {
result = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('roleId', sql.Int, guestRoleId)
.input('role', sql.NVarChar, guestRoleName)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, Email, FullName, RoleId, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId
VALUES (@username, @password, @email, @fullname, @roleId, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, DATEADD(HOUR, 7, SYSUTCDATETIME())))`);
} else {
result = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('role', sql.NVarChar, guestRoleName)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, Email, FullName, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role
VALUES (@username, @password, @email, @fullname, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, DATEADD(HOUR, 7, SYSUTCDATETIME())))`);
}
const inserted = result.recordset[0];
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
if (hasRoleIdColumn && guestRoleId !== null) {
await pool.request()
.input('guestRoleId', sql.Int, guestRoleId)
.query(`UPDATE Users
SET RoleId = @guestRoleId,
Role = 'Guest'
WHERE LOWER(Role) = 'guest'
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
}
const emailResult = await sendVerificationEmail({
email: normalizedEmail,
username: safeUsername,
token
});
const responsePayload = {
success: true,
message: emailResult.sent
? 'Registration successful. Please check your email to confirm your account.'
: 'Registration successful, but email sending is not configured. Please contact administrator or use verification preview link in development.',
registrationPendingVerification: true,
emailSent: emailResult.sent,
userId: inserted?.UserId
};
if (!IS_PRODUCTION && emailResult.previewUrl) {
responsePayload.verificationPreviewUrl = emailResult.previewUrl;
}
if (!IS_PRODUCTION && emailResult.reason && !emailResult.sent) {
responsePayload.emailError = emailResult.reason;
}
res.json(responsePayload);
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ success: false, message: 'Registration failed' });
}
});
app.get('/api/auth/verify-email', async (req, res) => {
try {
const token = String(req.query.token || '').trim();
if (!token) {
return res.status(400).json({ success: false, message: 'Verification token is required' });
}
const tokenHash = hashVerificationToken(token);
const result = await pool.request()
.input('token', sql.NVarChar, tokenHash)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, EmailVerifyTokenExpires
FROM Users
WHERE EmailVerifyToken = @token`);
if (result.recordset.length === 0) {
return res.status(400).json({ success: false, message: 'Verification token is invalid or already used' });
}
const verifiedUser = result.recordset[0];
const expiresAt = verifiedUser.EmailVerifyTokenExpires ? new Date(verifiedUser.EmailVerifyTokenExpires) : null;
if (!expiresAt || expiresAt.getTime() < Date.now()) {
return res.status(400).json({ success: false, message: 'Verification token has expired. Please request a new email.' });
}
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query(`UPDATE Users
SET EmailVerified = 1,
EmailVerifiedAt = DATEADD(HOUR, 7, SYSUTCDATETIME()),
EmailVerifyToken = NULL,
EmailVerifyTokenExpires = NULL
WHERE UserId = @userId`);
if (!verifiedUser.IsActive) {
return res.status(403).json({
success: false,
message: 'Email confirmed, but account is inactive. Please contact administrator.'
});
}
// Align verification flow with login payload shape to support auto-login on verify page.
const { EmailVerifyTokenExpires: _expires, ...safeUser } = verifiedUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query('UPDATE Users SET LastLogin = DATEADD(HOUR, 7, SYSUTCDATETIME()) WHERE UserId = @userId');
await destroyAuthSession(req, res);
await createAuthSession(verifiedUser.UserId, req, res, false);
res.json({
success: true,
message: 'Email confirmed successfully. Logging you in...',
autoLogin: true,
user
});
} catch (err) {
console.error('Verify email error:', err.message);
res.status(500).json({ success: false, message: 'Email verification failed' });
}
});
app.post('/api/auth/resend-verification', accountRecoveryRateLimit, async (req, res) => {
try {
const identifier = String(req.body?.identifier || req.body?.email || '').trim();
if (!identifier) {
return res.status(400).json({ success: false, message: 'Username or email is required' });
}
const result = await pool.request()
.input('identifier', sql.NVarChar, identifier)
.query(`SELECT TOP 1 UserId, Username, Email, EmailVerified
FROM Users
WHERE Username = @identifier OR Email = @identifier`);
if (result.recordset.length === 0) {
return res.json({ success: true, message: 'If the account exists, a confirmation email has been sent.' });
}
const user = result.recordset[0];
if (user.EmailVerified) {
return res.json({ success: true, message: 'This email is already confirmed.' });
}
if (!user.Email) {
return res.status(400).json({ success: false, message: 'This account has no email. Please contact administrator.' });
}
const { token, tokenHash } = generateEmailVerificationToken();
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('tokenHash', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`UPDATE Users
SET EmailVerifyToken = @tokenHash,
EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, DATEADD(HOUR, 7, SYSUTCDATETIME()))
WHERE UserId = @userId`);
const emailResult = await sendVerificationEmail({
email: user.Email,
username: user.Username,
token
});
const payload = {
success: true,
message: emailResult.sent
? 'Verification email sent. Please check your inbox.'
: 'SMTP is not configured. Use development preview link or configure SMTP.',
emailSent: emailResult.sent
};
if (!IS_PRODUCTION && emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (!IS_PRODUCTION && emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Resend verification error:', err.message);
res.status(500).json({ success: false, message: 'Cannot resend verification email right now' });
}
});
app.post('/api/auth/forgot-password', accountRecoveryRateLimit, async (req, res) => {
try {
await ensurePasswordResetColumns();
const username = String(req.body?.username || '').trim();
const email = String(req.body?.email || '').trim().toLowerCase();
if (!username || !email) {
return res.status(400).json({ success: false, message: 'Username and email are required' });
}
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('email', sql.NVarChar, email)
.query(`SELECT TOP 1 UserId, Username, Email, IsActive
FROM Users
WHERE Username = @username
AND LOWER(ISNULL(Email, '')) = @email`);
if (result.recordset.length === 0 || !result.recordset[0].IsActive) {
return res.json({
success: true,
message: 'If the username and email match, a password reset email has been sent.'
});
}
const user = result.recordset[0];
const { token, tokenHash } = generateEmailVerificationToken();
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('tokenHash', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, PASSWORD_RESET_TOKEN_TTL_MINUTES)
.query(`UPDATE Users
SET PasswordResetToken = @tokenHash,
PasswordResetTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, DATEADD(HOUR, 7, SYSUTCDATETIME()))
WHERE UserId = @userId`);
const emailResult = await sendPasswordResetEmail({
email: user.Email,
username: user.Username,
token
});
const payload = {
success: true,
message: emailResult.sent
? 'Password reset email sent. Please check your inbox.'
: 'SMTP is not configured. Use development reset link or configure SMTP.',
emailSent: emailResult.sent
};
if (!IS_PRODUCTION && emailResult.previewUrl) {
payload.resetPreviewUrl = emailResult.previewUrl;
}
if (!IS_PRODUCTION && emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Forgot password error:', err.message);
res.status(500).json({ success: false, message: 'Cannot process forgot password request right now' });
}
});
app.post('/api/auth/reset-password', accountRecoveryRateLimit, async (req, res) => {
try {
await ensurePasswordResetColumns();
const token = String(req.body?.token || '').trim();
const newPassword = String(req.body?.newPassword || req.body?.password || '');
if (!token || !newPassword) {
return res.status(400).json({ success: false, message: 'Reset token and new password are required' });
}
if (newPassword.length < 12 || newPassword.length > 256) {
return res.status(400).json({ success: false, message: 'New password must be between 12 and 256 characters' });
}
const tokenHash = hashVerificationToken(token);
const result = await pool.request()
.input('token', sql.NVarChar, tokenHash)
.query(`SELECT TOP 1 UserId, PasswordResetTokenExpires, IsActive
FROM Users
WHERE PasswordResetToken = @token`);
if (result.recordset.length === 0) {
return res.status(400).json({ success: false, message: 'Reset token is invalid or already used' });
}
const user = result.recordset[0];
if (!user.IsActive) {
return res.status(403).json({ success: false, message: 'Account is inactive. Please contact administrator.' });
}
const expiresAt = user.PasswordResetTokenExpires ? new Date(user.PasswordResetTokenExpires) : null;
if (!expiresAt || expiresAt.getTime() < Date.now()) {
return res.status(400).json({ success: false, message: 'Reset token has expired. Please request a new reset email.' });
}
const hashedPassword = await hashPassword(newPassword);
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('password', sql.NVarChar, hashedPassword)
.query(`UPDATE Users
SET Password = @password,
ViewPassword = NULL,
PasswordResetToken = NULL,
PasswordResetTokenExpires = NULL
WHERE UserId = @userId`);
await invalidateUserSessions(user.UserId);
res.json({ success: true, message: 'Password reset successful. You can sign in now.' });
} catch (err) {
console.error('Reset password error:', err.message);
res.status(500).json({ success: false, message: 'Password reset failed' });
}
});
// Public, non-sensitive health check for container monitoring.
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/api/auth/session', requireAuth, (req, res) => {
res.json({ success: true, user: req.user });
});
app.post('/api/auth/logout', requireAuth, async (req, res) => {
try {
await destroyAuthSession(req, res);
res.json({ success: true, message: 'Signed out' });
} catch (err) {
console.error('Logout error:', err.message);
clearSessionCookie(res);
res.status(500).json({ success: false, message: 'Unable to sign out cleanly' });
}
});
// Every API declared below this point requires a valid server-side session.
app.use('/api', requireAuth);
// Middleware for role-based access control
function normalizeRole(value) {
return String(value || '').trim().toLowerCase();
}
function requireRoles(roles = [], message = 'Access denied') {
const allowedRoles = new Set(roles.map(normalizeRole));
return (req, res, next) => {
const userRole = getRequesterRole(req);
if (!allowedRoles.has(userRole)) {
return res.status(403).json({ success: false, message });
}
next();
};
}
const requireAdmin = requireRoles(['admin'], 'Admin access required');
const requireAssetOrAdmin = requireRoles(['asset', 'admin'], 'Asset or Admin access required');
// ==========================================
// API ROUTES - Roles
// ==========================================
// Get all roles
app.get('/api/roles', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName');
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
// Get role by ID
app.get('/api/roles/:id', async (req, res) => {
try {
const result = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT * FROM Roles WHERE RoleId = @roleId');
if (result.recordset.length > 0) {
res.json({ success: true, data: result.recordset[0] });
} else {
res.status(404).json({ success: false, message: 'Role not found' });
}
} catch (err) {
sendInternalError(res, err);
}
});
// Create new role (Admin only)
app.post('/api/roles', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
const result = await pool.request()
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName)
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES (@roleName, @description);
SELECT SCOPE_IDENTITY() as RoleId
END
ELSE
BEGIN
SELECT RoleId FROM Roles WHERE RoleName = @roleName
END`);
res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId });
} catch (err) {
sendInternalError(res, err);
}
});
// Update role (Admin only)
app.put('/api/roles/:id', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
await pool.request()
.input('roleId', sql.Int, req.params.id)
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`UPDATE Roles
SET RoleName = @roleName,
Description = @description
WHERE RoleId = @roleId`);
res.json({ success: true, message: 'Role updated' });
} catch (err) {
sendInternalError(res, err);
}
});
// Delete role (Admin only)
app.delete('/api/roles/:id', requireAdmin, async (req, res) => {
try {
// Check if role is in use
const check = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId');
if (check.recordset[0].Count > 0) {
return res.status(400).json({
success: false,
message: 'Cannot delete role - it is assigned to users'
});
}
await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('DELETE FROM Roles WHERE RoleId = @roleId');
res.json({ success: true, message: 'Role deleted' });
} catch (err) {
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Users
// ==========================================
// Get all users
app.get('/api/users', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
u.Status, u.CreatedDate, u.LastLogin, u.IsActive, u.EmailVerified, u.EmailVerifiedAt
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
ORDER BY u.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
app.get('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const result = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
if (result.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.json({ success: true, data: result.recordset[0] });
} catch (err) {
sendInternalError(res, err);
}
});
app.put('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const fullname = String(req.body?.fullname || '').trim();
const incomingEmail = String(req.body?.email || '').trim().toLowerCase();
const currentPassword = String(req.body?.currentPassword || '');
const newPassword = String(req.body?.newPassword || '').trim();
if (!fullname) {
return res.status(400).json({ success: false, message: 'Full name is required' });
}
if (!incomingEmail) {
return res.status(400).json({ success: false, message: 'Email is required' });
}
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(incomingEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
const userResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, Password, Role, RoleId
FROM Users
WHERE UserId = @userId`);
if (userResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
const existingUser = userResult.recordset[0];
const emailChanged = String(existingUser.Email || '').toLowerCase() !== incomingEmail;
const shouldChangePassword = newPassword.length > 0;
if (shouldChangePassword && (newPassword.length < 12 || newPassword.length > 256)) {
return res.status(400).json({ success: false, message: 'New password must be between 12 and 256 characters' });
}
if (shouldChangePassword || emailChanged) {
if (!currentPassword) {
return res.status(400).json({ success: false, message: 'Current password is required for security-sensitive profile changes' });
}
const isCurrentPasswordValid = await verifyPassword(currentPassword, existingUser.Password);
if (!isCurrentPasswordValid) {
return res.status(400).json({ success: false, message: 'Current password is incorrect' });
}
}
if (emailChanged) {
const duplicateEmailResult = await pool.request()
.input('email', sql.NVarChar, incomingEmail)
.input('userId', sql.Int, userId)
.query('SELECT TOP 1 UserId FROM Users WHERE Email = @email AND UserId <> @userId');
if (duplicateEmailResult.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Email already exists' });
}
}
const request = pool.request()
.input('userId', sql.Int, userId)
.input('fullname', sql.NVarChar, fullname)
.input('email', sql.NVarChar, incomingEmail);
let token;
if (emailChanged) {
const tokenResult = generateEmailVerificationToken();
token = tokenResult.token;
request.input('emailVerifyToken', sql.NVarChar, tokenResult.tokenHash);
request.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES);
}
if (shouldChangePassword) {
const hashedPassword = await hashPassword(newPassword);
request.input('password', sql.NVarChar, hashedPassword);
}
await request.query(`UPDATE Users
SET FullName = @fullname,
Email = @email
${shouldChangePassword ? ', Password = @password, ViewPassword = NULL' : ''}
${emailChanged ? ', EmailVerified = 0, EmailVerifiedAt = NULL, EmailVerifyToken = @emailVerifyToken, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, DATEADD(HOUR, 7, SYSUTCDATETIME()))' : ''}
WHERE UserId = @userId`);
let emailResult = { sent: true };
if (emailChanged) {
emailResult = await sendVerificationEmail({
email: incomingEmail,
username: existingUser.Username,
token
});
}
const safeResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
const payload = {
success: true,
message: emailChanged
? 'Profile updated. Please confirm your new email address.'
: (shouldChangePassword ? 'Profile and password updated successfully' : 'Profile updated successfully'),
user: safeResult.recordset[0],
verificationRequired: emailChanged,
emailSent: emailChanged ? emailResult.sent : undefined
};
if (emailChanged) {
await invalidateUserSessions(userId);
clearSessionCookie(res);
payload.sessionEnded = true;
} else if (shouldChangePassword) {
await invalidateUserSessions(userId, req.authSessionId);
}
if (!IS_PRODUCTION && emailChanged && emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (!IS_PRODUCTION && emailChanged && emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Update profile error:', err.message);
res.status(500).json({ success: false, message: 'Cannot update profile right now' });
}
});
// Get user by ID
app.get('/api/users/:id', requireAdmin, async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.params.id)
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId,
u.Status, u.CreatedDate, u.LastLogin, u.IsActive,
u.EmailVerified, u.EmailVerifiedAt, r.RoleName
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
WHERE u.UserId = @userId`);
if (result.recordset.length > 0) {
res.json({
success: true,
data: { ...result.recordset[0], PasswordAvailable: false }
});
} else {
res.status(404).json({ success: false, message: 'User not found' });
}
} catch (err) {
sendInternalError(res, err);
}
});
// Create new user (Admin only)
app.post('/api/users', requireAdmin, async (req, res) => {
try {
const { username, password, email, fullname, roleId } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password are required' });
}
if (!/^[A-Za-z0-9._-]{3,50}$/.test(String(username).trim())) {
return res.status(400).json({ success: false, message: 'Username format is invalid' });
}
if (String(password).length < 12 || String(password).length > 256) {
return res.status(400).json({ success: false, message: 'Password must be between 12 and 256 characters' });
}
const hashedPassword = await hashPassword(password);
const finalRoleId = roleId || 2; // Default to Guest role
// Get role name from Roles table
const roleResult = await pool.request()
.input('roleId', sql.Int, finalRoleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest';
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('roleId', sql.Int, finalRoleId)
.input('role', sql.NVarChar, roleName)
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
BEGIN
INSERT INTO Users (Username, Password, Email, FullName, RoleId, Role, IsActive, EmailVerified, EmailVerifiedAt)
VALUES (@username, @password, @email, @fullname, @roleId, @role, 1, 1, DATEADD(HOUR, 7, SYSUTCDATETIME()));
SELECT SCOPE_IDENTITY() as UserId
END
ELSE
BEGIN
SELECT NULL as UserId
END`);
if (result.recordset[0].UserId) {
res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId });
} else {
res.status(400).json({ success: false, message: 'Username already exists' });
}
} catch (err) {
sendInternalError(res, err);
}
});
// Update user (Admin only)
app.put('/api/users/:id', requireAdmin, async (req, res) => {
try {
const { email, fullname, roleId, status, isActive, password } = req.body;
const nextPassword = typeof password === 'string' ? password.trim() : '';
const shouldUpdatePassword = nextPassword.length > 0;
if (shouldUpdatePassword && (nextPassword.length < 12 || nextPassword.length > 256)) {
return res.status(400).json({ success: false, message: 'Password must be between 12 and 256 characters' });
}
// Get role name from Roles table
let roleName = '';
if (roleId) {
const roleResult = await pool.request()
.input('roleId', sql.Int, roleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : '';
}
const request = pool.request()
.input('userId', sql.Int, req.params.id)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('roleId', sql.Int, roleId)
.input('role', sql.NVarChar, roleName)
.input('status', sql.NVarChar, status)
.input('isActive', sql.Bit, isActive);
if (shouldUpdatePassword) {
const hashedPassword = await hashPassword(nextPassword);
request.input('password', sql.NVarChar, hashedPassword);
}
await request.query(`UPDATE Users
SET Email = @email,
FullName = @fullname,
RoleId = @roleId,
Role = @role,
Status = @status,
IsActive = @isActive
${shouldUpdatePassword ? ', Password = @password, ViewPassword = NULL' : ''}
WHERE UserId = @userId`);
if (shouldUpdatePassword || !isActive) {
await invalidateUserSessions(Number(req.params.id));
}
res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' });
} catch (err) {
console.error('Update user error:', err.message);
res.status(500).json({ success: false, message: 'Unable to update user' });
}
});
// Delete user (Admin only)
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
try {
const targetUserId = Number(req.params.id);
if (!Number.isInteger(targetUserId) || targetUserId <= 0) {
return res.status(400).json({ success: false, message: 'Invalid user id' });
}
if (targetUserId === getUserIdFromRequest(req)) {
return res.status(400).json({
success: false,
message: 'You cannot delete your own signed-in account'
});
}
const adminCheck = await pool.request()
.input('userId', sql.Int, targetUserId)
.query(`SELECT TOP 1 LOWER(ISNULL(Role, '')) AS Role FROM Users WHERE UserId = @userId;
SELECT COUNT(*) AS ActiveAdminCount FROM Users WHERE LOWER(ISNULL(Role, '')) = 'admin' AND IsActive = 1;`);
const targetRole = adminCheck.recordsets?.[0]?.[0]?.Role;
const activeAdminCount = Number(adminCheck.recordsets?.[1]?.[0]?.ActiveAdminCount || 0);
if (targetRole === 'admin' && activeAdminCount <= 1) {
return res.status(400).json({ success: false, message: 'The last active administrator cannot be deleted' });
}
// Delete associated accounts first
await pool.request()
.input('userId', sql.Int, targetUserId)
.query('DELETE FROM Accounts WHERE UserId = @userId');
await pool.request()
.input('userId', sql.Int, targetUserId)
.query(`
IF COL_LENGTH('dbo.ConsumableExportHistory', 'RecipientUserId') IS NOT NULL
UPDATE ConsumableExportHistory SET RecipientUserId = NULL WHERE RecipientUserId = @userId
IF COL_LENGTH('dbo.AssetBorrowRequests', 'ProcessedBy') IS NOT NULL
UPDATE AssetBorrowRequests SET ProcessedBy = NULL WHERE ProcessedBy = @userId
IF COL_LENGTH('dbo.ConsumableBorrowRequests', 'ProcessedBy') IS NOT NULL
UPDATE ConsumableBorrowRequests SET ProcessedBy = NULL WHERE ProcessedBy = @userId
`);
// Then delete the user
await pool.request()
.input('userId', sql.Int, targetUserId)
.query('DELETE FROM Users WHERE UserId = @userId');
res.json({ success: true, message: 'User deleted' });
} catch (err) {
console.error('Delete user error:', err.message);
res.status(500).json({ success: false, message: 'Unable to delete user' });
}
});
// ==========================================
// API ROUTES - Applications
// ==========================================
// Get all applications
app.get('/api/applications', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT AppId, Name, Type, Status, Icon, Description, Url, CreatedDate, UpdatedDate FROM Applications ORDER BY Name');
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
// Create application
app.post('/api/applications', requireAdmin, async (req, res) => {
try {
const { name, type, status, icon, description, url } = req.body;
const safeUrl = normalizeOptionalHttpUrl(url);
if (!String(name || '').trim() || safeUrl === null) {
return res.status(400).json({ success: false, message: 'Application name and a valid HTTP(S) URL are required' });
}
const result = await pool.request()
.input('name', sql.NVarChar, name)
.input('type', sql.NVarChar, type)
.input('status', sql.NVarChar, status)
.input('icon', sql.NVarChar, icon)
.input('description', sql.NVarChar, description)
.input('url', sql.NVarChar, safeUrl)
.query(`INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
VALUES (@name, @type, @status, @icon, @description, @url);
SELECT SCOPE_IDENTITY() as AppId`);
res.json({ success: true, message: 'Application created', appId: result.recordset[0].AppId });
} catch (err) {
sendInternalError(res, err);
}
});
// Update application
app.put('/api/applications/:id', requireAdmin, async (req, res) => {
try {
const { name, type, status, icon, description, url } = req.body;
const safeUrl = normalizeOptionalHttpUrl(url);
if (!String(name || '').trim() || safeUrl === null) {
return res.status(400).json({ success: false, message: 'Application name and a valid HTTP(S) URL are required' });
}
await pool.request()
.input('appId', sql.Int, req.params.id)
.input('name', sql.NVarChar, name)
.input('type', sql.NVarChar, type)
.input('status', sql.NVarChar, status)
.input('icon', sql.NVarChar, icon)
.input('description', sql.NVarChar, description)
.input('url', sql.NVarChar, safeUrl)
.query(`UPDATE Applications
SET Name = @name,
Type = @type,
Status = @status,
Icon = @icon,
Description = @description,
Url = @url,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE AppId = @appId`);
res.json({ success: true, message: 'Application updated' });
} catch (err) {
sendInternalError(res, err);
}
});
// Delete application
app.delete('/api/applications/:id', requireAdmin, async (req, res) => {
try {
await pool.request()
.input('appId', sql.Int, req.params.id)
.query('DELETE FROM Applications WHERE AppId = @appId');
res.json({ success: true, message: 'Application deleted' });
} catch (err) {
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Accounts
// ==========================================
function accountRecordForResponse(record) {
const storedPassword = String(record?.AccountPassword || '');
return {
...record,
AccountPassword: '',
PasswordAvailable: Boolean(storedPassword)
};
}
function currentUserCanAccessAccount(req, ownerUserId) {
return getRequesterRole(req) === 'admin' || Number(ownerUserId) === getUserIdFromRequest(req);
}
app.get('/api/accounts/:id/secret', credentialRevealRateLimit, async (req, res) => {
try {
const accountId = Number(req.params.id);
if (!Number.isInteger(accountId) || accountId <= 0) {
return res.status(400).json({ success: false, message: 'Invalid account id' });
}
const result = await pool.request()
.input('accountId', sql.Int, accountId)
.query('SELECT TOP 1 UserId, AccountPassword FROM Accounts WHERE AccountId = @accountId');
const account = result.recordset[0];
if (!account) {
return res.status(404).json({ success: false, message: 'Account not found' });
}
if (!currentUserCanAccessAccount(req, account.UserId)) {
return res.status(403).json({ success: false, message: 'You cannot reveal credentials assigned to another user' });
}
const storedPassword = String(account.AccountPassword || '');
const password = storedPassword
? (isEncryptedSensitiveValue(storedPassword) ? decryptSensitiveValue(storedPassword) : storedPassword)
: '';
if (storedPassword && password === null) {
return res.status(500).json({ success: false, message: 'Stored credential cannot be decrypted' });
}
res.json({ success: true, password: password || '' });
} catch (err) {
console.error('Reveal account password error:', err.message);
res.status(500).json({ success: false, message: 'Unable to reveal stored credential' });
}
});
// Get accounts for a user
app.get('/api/accounts/user/:userId', async (req, res) => {
try {
const requestedUserId = Number(req.params.userId);
if (!Number.isInteger(requestedUserId) || requestedUserId <= 0) {
return res.status(400).json({ success: false, message: 'Invalid user id' });
}
if (!currentUserCanAccessAccount(req, requestedUserId)) {
return res.status(403).json({ success: false, message: 'You cannot access credentials assigned to another user' });
}
const result = await pool.request()
.input('userId', sql.Int, requestedUserId)
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
WHERE a.UserId = @userId
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
// Get all accounts (from all users)
app.get('/api/accounts/all', async (req, res) => {
try {
const requesterId = getUserIdFromRequest(req);
const canViewAll = getRequesterRole(req) === 'admin';
const request = pool.request();
if (!canViewAll) {
request.input('requesterId', sql.Int, requesterId);
}
const result = await request
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
${canViewAll ? '' : 'WHERE a.UserId = @requesterId'}
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset.map(accountRecordForResponse) });
} catch (err) {
console.error('Get all accounts error:', err.message);
res.status(500).json({ success: false, message: 'Unable to load accounts' });
}
});
// Create account
app.post('/api/accounts', async (req, res) => {
try {
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
const requesterId = getUserIdFromRequest(req);
const targetUserId = getRequesterRole(req) === 'admin' ? Number(userId) : requesterId;
if (!Number.isInteger(targetUserId) || targetUserId <= 0 || !Number.isInteger(Number(appId)) || Number(appId) <= 0) {
return res.status(400).json({ success: false, message: 'User and application are required' });
}
if (!String(accountUsername || '').trim() || !String(accountPassword || '')) {
return res.status(400).json({ success: false, message: 'Account username and password are required' });
}
const result = await pool.request()
.input('userId', sql.Int, targetUserId)
.input('appId', sql.Int, Number(appId))
.input('accountUsername', sql.NVarChar, String(accountUsername).trim().slice(0, 100))
.input('accountPassword', sql.NVarChar(2048), encryptSensitiveValue(String(accountPassword)))
.input('email', sql.NVarChar, email)
.input('accessLevel', sql.NVarChar, accessLevel)
.input('notes', sql.NVarChar, notes)
.query(`INSERT INTO Accounts (UserId, AppId, AccountUsername, AccountPassword, Email, AccessLevel, Notes)
VALUES (@userId, @appId, @accountUsername, @accountPassword, @email, @accessLevel, @notes);
SELECT SCOPE_IDENTITY() as AccountId`);
res.json({ success: true, message: 'Account created', accountId: result.recordset[0].AccountId });
} catch (err) {
sendInternalError(res, err);
}
});
// Update account
app.put('/api/accounts/:id', async (req, res) => {
try {
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
const accountId = Number(req.params.id);
if (!Number.isInteger(accountId) || accountId <= 0) {
return res.status(400).json({ success: false, message: 'Invalid account id' });
}
const existingResult = await pool.request()
.input('accountId', sql.Int, accountId)
.query('SELECT TOP 1 UserId, AccountPassword FROM Accounts WHERE AccountId = @accountId');
const existing = existingResult.recordset[0];
if (!existing) {
return res.status(404).json({ success: false, message: 'Account not found' });
}
if (!currentUserCanAccessAccount(req, existing.UserId)) {
return res.status(403).json({ success: false, message: 'You cannot update credentials assigned to another user' });
}
const targetUserId = getRequesterRole(req) === 'admin' ? Number(userId) : getUserIdFromRequest(req);
const nextPassword = String(accountPassword || '');
const storedPassword = nextPassword ? encryptSensitiveValue(nextPassword) : existing.AccountPassword;
await pool.request()
.input('accountId', sql.Int, accountId)
.input('userId', sql.Int, targetUserId)
.input('appId', sql.Int, Number(appId))
.input('accountUsername', sql.NVarChar, accountUsername)
.input('accountPassword', sql.NVarChar(2048), storedPassword)
.input('email', sql.NVarChar, email)
.input('accessLevel', sql.NVarChar, accessLevel)
.input('notes', sql.NVarChar, notes)
.query(`UPDATE Accounts
SET UserId = @userId,
AppId = @appId,
AccountUsername = @accountUsername,
AccountPassword = @accountPassword,
Email = @email,
AccessLevel = @accessLevel,
Notes = @notes,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE AccountId = @accountId`);
res.json({ success: true, message: 'Account updated' });
} catch (err) {
sendInternalError(res, err);
}
});
// Delete account
app.delete('/api/accounts/:id', async (req, res) => {
try {
const accountId = Number(req.params.id);
if (!Number.isInteger(accountId) || accountId <= 0) {
return res.status(400).json({ success: false, message: 'Invalid account id' });
}
const existingResult = await pool.request()
.input('accountId', sql.Int, accountId)
.query('SELECT TOP 1 UserId FROM Accounts WHERE AccountId = @accountId');
const existing = existingResult.recordset[0];
if (!existing) {
return res.status(404).json({ success: false, message: 'Account not found' });
}
if (!currentUserCanAccessAccount(req, existing.UserId)) {
return res.status(403).json({ success: false, message: 'You cannot delete credentials assigned to another user' });
}
await pool.request()
.input('accountId', sql.Int, accountId)
.query('DELETE FROM Accounts WHERE AccountId = @accountId');
res.json({ success: true, message: 'Account deleted' });
} catch (err) {
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Asset Departments
// ==========================================
app.get('/api/asset-departments', async (req, res) => {
try {
await syncAssetDepartmentsFromInventory();
const result = await pool.request().query(`
SELECT
d.DepartmentId,
d.DepartmentName,
d.CreatedDate,
d.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetDepartments d
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName)))
GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate
ORDER BY d.DepartmentName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
try {
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const existed = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
const inserted = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
SELECT SCOPE_IDENTITY() AS DepartmentId;
`);
res.json({
success: true,
message: 'Đã thêm phòng ban',
departmentId: inserted.recordset[0]?.DepartmentId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
sendInternalError(res, err);
}
});
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ệ?' });
}
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const currentDepartment = currentResult.recordset[0];
const currentName = String(currentDepartment.DepartmentName || '').trim();
if (currentName.toLowerCase() === departmentName.toLowerCase()) {
return res.json({ success: true, message: 'Đã cập nhật phòng ban' });
}
const duplicated = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE DepartmentId <> @departmentId
AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetDepartments
SET DepartmentName = @departmentName,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE DepartmentId = @departmentId
`);
await new sql.Request(transaction)
.input('oldDepartmentName', sql.NVarChar, currentName)
.input('newDepartmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = @newDepartmentName,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName)
`);
await transaction.commit();
res.json({ success: true, message: 'Đã cập nhật phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
sendInternalError(res, err);
}
});
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ệ?' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = NULL,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName)
`);
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.query(`
DELETE FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
await transaction.commit();
res.json({ success: true, message: 'Đã xóa phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Asset Projects
// ==========================================
app.get('/api/asset-projects', async (req, res) => {
try {
await syncAssetProjectsFromInventory();
const result = await pool.request().query(`
SELECT
p.ProjectId,
p.ProjectName,
p.CreatedDate,
p.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetProjects p
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Project))) = LOWER(LTRIM(RTRIM(p.ProjectName)))
GROUP BY p.ProjectId, p.ProjectName, p.CreatedDate, p.UpdatedDate
ORDER BY p.ProjectName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
app.post('/api/asset-projects', requireAssetOrAdmin, async (req, res) => {
try {
const projectName = normalizeProjectName(req.body?.projectName);
if (!projectName) {
return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' });
}
await syncAssetProjectsFromInventory();
const existed = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.query(`
SELECT TOP 1 ProjectId
FROM AssetProjects
WHERE LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
const inserted = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.query(`
INSERT INTO AssetProjects (ProjectName)
VALUES (@projectName);
SELECT SCOPE_IDENTITY() AS ProjectId;
`);
res.json({
success: true,
message: 'Da them du an',
projectId: inserted.recordset[0]?.ProjectId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetProjects_ProjectName')) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
sendInternalError(res, err);
}
});
app.put('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => {
try {
const projectId = Number(req.params.id);
if (!Number.isInteger(projectId) || projectId <= 0) {
return res.status(400).json({ success: false, message: 'Ma du an khong hop le' });
}
const projectName = normalizeProjectName(req.body?.projectName);
if (!projectName) {
return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' });
}
await syncAssetProjectsFromInventory();
const currentResult = await pool.request()
.input('projectId', sql.Int, projectId)
.query(`
SELECT ProjectId, ProjectName
FROM AssetProjects
WHERE ProjectId = @projectId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Khong tim thay du an' });
}
const currentProject = currentResult.recordset[0];
const currentName = String(currentProject.ProjectName || '').trim();
if (currentName.toLowerCase() === projectName.toLowerCase()) {
return res.json({ success: true, message: 'Da cap nhat du an' });
}
const duplicated = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.input('projectId', sql.Int, projectId)
.query(`
SELECT TOP 1 ProjectId
FROM AssetProjects
WHERE ProjectId <> @projectId
AND LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('projectId', sql.Int, projectId)
.input('projectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetProjects
SET ProjectName = @projectName,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE ProjectId = @projectId
`);
await new sql.Request(transaction)
.input('oldProjectName', sql.NVarChar, currentName)
.input('newProjectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetInventory
SET Project = @newProjectName,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@oldProjectName)
`);
await transaction.commit();
res.json({ success: true, message: 'Da cap nhat du an' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetProjects_ProjectName')) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
sendInternalError(res, err);
}
});
app.delete('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => {
try {
const projectId = Number(req.params.id);
if (!Number.isInteger(projectId) || projectId <= 0) {
return res.status(400).json({ success: false, message: 'Ma du an khong hop le' });
}
await syncAssetProjectsFromInventory();
const currentResult = await pool.request()
.input('projectId', sql.Int, projectId)
.query(`
SELECT ProjectId, ProjectName
FROM AssetProjects
WHERE ProjectId = @projectId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Khong tim thay du an' });
}
const projectName = String(currentResult.recordset[0].ProjectName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('projectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetInventory
SET Project = NULL,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@projectName)
`);
await new sql.Request(transaction)
.input('projectId', sql.Int, projectId)
.query(`
DELETE FROM AssetProjects
WHERE ProjectId = @projectId
`);
await transaction.commit();
res.json({ success: true, message: 'Da xoa du an' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Asset Inventory
// ==========================================
app.get('/api/asset-borrows', async (req, res) => {
try {
const requesterRole = getRequesterRole(req);
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,
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,
br.RejectReason,
br.CreatedBy,
br.ProcessedBy,
br.ProcessedByName,
br.ProcessedDate,
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
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
app.get('/api/asset-borrows/:id/history', async (req, res) => {
try {
const requesterRole = getRequesterRole(req);
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) {
sendInternalError(res, err);
}
});
app.post('/api/asset-borrows', async (req, res) => {
let transaction;
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,
ExportInPeriod,
EndingBalance,
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 derivedEndingBalance = Math.max(
parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed,
0
);
const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance);
const endingBalance = storedEndingBalance !== null ? storedEndingBalance : derivedEndingBalance;
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})`
});
}
}
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')
.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;
`);
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: createdRequestId
}
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
}
sendInternalError(res, err);
}
});
app.post('/api/asset-borrows/:id/return', async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
const requesterRole = getRequesterRole(req);
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 sendInternalError(res, err);
}
});
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.user?.FullName
|| req.user?.Username
|| '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,
br.CreatedBy,
ai.AssetCode,
ai.AssetName,
ai.Quantity,
ai.ImportInPeriod,
ai.ExportInPeriod,
ai.EndingBalance,
ai.NewQuantity,
ai.UsedQuantity,
ai.Status,
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 derivedEndingBalance = Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- currentBorrowed,
0
);
const baseEndingBalance = parseOptionalNonNegativeInteger(targetRequest.EndingBalance);
const endingBalance = baseEndingBalance !== null ? baseEndingBalance : derivedEndingBalance;
const baseNewQuantity = parseOptionalNonNegativeInteger(targetRequest.NewQuantity);
const baseUsedQuantity = parseOptionalNonNegativeInteger(targetRequest.UsedQuantity);
const stockBuckets = normalizeAssetStockBuckets(
endingBalance,
baseNewQuantity !== null ? baseNewQuantity : endingBalance,
baseUsedQuantity !== null ? baseUsedQuantity : 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.'
});
}
const borrowFromNew = Math.min(stockBuckets.newQuantity, requestQuantity);
const borrowFromUsed = Math.max(requestQuantity - borrowFromNew, 0);
const nextEndingBalance = Math.max(endingBalance - requestQuantity, 0);
const nextNewQuantity = Math.max(stockBuckets.newQuantity - borrowFromNew, 0);
const nextUsedQuantity = Math.max(stockBuckets.usedQuantity - borrowFromUsed, 0);
const nextBorrowingQuantity = currentBorrowed + requestQuantity;
const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextBorrowingQuantity);
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary)
.input('exportInPeriod', sql.Int, nextBorrowingQuantity)
.input('endingBalance', sql.Int, nextEndingBalance)
.input('newQuantity', sql.Int, nextNewQuantity)
.input('usedQuantity', sql.Int, nextUsedQuantity)
.input('status', sql.NVarChar, nextStatus)
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = @exportedBy,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
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;
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);
const derivedEndingBalance = Math.max(quantity + importInPeriod - parseNonNegativeInteger(targetRequest.ExportInPeriod, 0), 0);
const baseEndingBalance = parseOptionalNonNegativeInteger(targetRequest.EndingBalance);
const currentEndingBalance = baseEndingBalance !== null ? baseEndingBalance : derivedEndingBalance;
const baseNewQuantity = parseOptionalNonNegativeInteger(targetRequest.NewQuantity);
const baseUsedQuantity = parseOptionalNonNegativeInteger(targetRequest.UsedQuantity);
const stockBuckets = normalizeAssetStockBuckets(
currentEndingBalance,
baseNewQuantity !== null ? baseNewQuantity : currentEndingBalance,
baseUsedQuantity !== null ? baseUsedQuantity : 0
);
const nextEndingBalance = Math.max(quantity + importInPeriod - remainingBorrowed, 0);
const nextBuckets = normalizeAssetStockBuckets(
nextEndingBalance,
stockBuckets.newQuantity,
stockBuckets.usedQuantity + requestQuantity
);
const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, remainingBorrowed);
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, nextEndingBalance)
.input('newQuantity', sql.Int, nextBuckets.newQuantity)
.input('usedQuantity', sql.Int, nextBuckets.usedQuantity)
.input('status', sql.NVarChar, nextStatus)
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
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 = DATEADD(HOUR, 7, SYSUTCDATETIME())
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 = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE BorrowId = @borrowId
`);
remainingToLink -= linkedQuantity;
}
}
}
}
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 = DATEADD(HOUR, 7, SYSUTCDATETIME()),
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
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 sendInternalError(res, err);
}
});
app.delete('/api/asset-borrows/:id', async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
const requesterRole = getRequesterRole(req);
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' });
}
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 BorrowId, RequestStatus, CreatedBy
FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK)
WHERE BorrowId = @borrowId
${canManageRequests ? '' : 'AND CreatedBy = @requesterId'}
`);
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'
});
}
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'
});
}
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 sendInternalError(res, err);
}
});
function createConsumableRequestError(message, statusCode = 400) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
async function applyConsumableReturn(transaction, {
exportHistoryId,
returnQuantity,
returnedByName,
returnNote,
createdBy
}) {
const exportResult = await new sql.Request(transaction)
.input('exportHistoryId', sql.Int, exportHistoryId)
.query(`
SELECT TOP 1
exports.ExportHistoryId,
exports.ConsumableId,
exports.ConsumableCode,
exports.ConsumableName,
exports.Unit,
exports.ExportQuantity,
exports.RecipientUserId,
exports.RecipientName,
exports.ProjectName,
ISNULL(returnSummary.ReturnedQuantity, 0) AS ReturnedQuantity,
inventory.ExportInPeriod,
inventory.EndingBalance
FROM ConsumableExportHistory exports WITH (UPDLOCK, HOLDLOCK)
INNER JOIN ConsumableInventory inventory WITH (UPDLOCK, HOLDLOCK)
ON inventory.ConsumableId = exports.ConsumableId
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns WITH (UPDLOCK, HOLDLOCK)
WHERE returns.ExportHistoryId = exports.ExportHistoryId
) returnSummary
WHERE exports.ExportHistoryId = @exportHistoryId
`);
const exportRow = exportResult.recordset?.[0];
if (!exportRow) {
throw createConsumableRequestError('Không tìm thấy phiếu xuất cần trả', 404);
}
if (String(exportRow.ProjectName || '').trim()) {
throw createConsumableRequestError('Vật tư đã xuất cho dự án không thuộc luồng mượn/trả');
}
const exportedQuantity = parseNonNegativeInteger(exportRow.ExportQuantity, 0);
const returnedQuantity = parseNonNegativeInteger(exportRow.ReturnedQuantity, 0);
const remainingQuantity = Math.max(exportedQuantity - returnedQuantity, 0);
if (remainingQuantity <= 0) {
throw createConsumableRequestError('Phiếu này đã được trả hết về kho');
}
if (returnQuantity > remainingQuantity) {
throw createConsumableRequestError(
`Số lượng trả (${returnQuantity}) vượt quá số lượng còn phải trả (${remainingQuantity})`
);
}
const previousExportInPeriod = parseNonNegativeInteger(exportRow.ExportInPeriod, 0);
const previousEndingBalance = parseNonNegativeInteger(exportRow.EndingBalance, 0);
const nextExportInPeriod = Math.max(previousExportInPeriod - returnQuantity, 0);
const nextEndingBalance = previousEndingBalance + returnQuantity;
await new sql.Request(transaction)
.input('consumableId', sql.Int, Number(exportRow.ConsumableId))
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.query(`
UPDATE ConsumableInventory
SET ExportInPeriod = @nextExportInPeriod,
EndingBalance = @nextEndingBalance,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE ConsumableId = @consumableId
`);
const returnResult = await new sql.Request(transaction)
.input('exportHistoryId', sql.Int, exportHistoryId)
.input('consumableId', sql.Int, Number(exportRow.ConsumableId))
.input('returnQuantity', sql.Int, returnQuantity)
.input('returnedByName', sql.NVarChar, returnedByName)
.input('returnNote', sql.NVarChar, returnNote)
.input('previousExportInPeriod', sql.Int, previousExportInPeriod)
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('previousEndingBalance', sql.Int, previousEndingBalance)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO ConsumableReturnHistory (
ExportHistoryId,
ConsumableId,
ReturnQuantity,
ReturnedByName,
ReturnNote,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
CreatedBy,
ReturnedDate
)
OUTPUT
INSERTED.ReturnHistoryId,
INSERTED.ExportHistoryId,
INSERTED.ConsumableId,
INSERTED.ReturnQuantity,
INSERTED.ReturnedByName,
INSERTED.ReturnNote,
INSERTED.PreviousExportInPeriod,
INSERTED.NextExportInPeriod,
INSERTED.PreviousEndingBalance,
INSERTED.NextEndingBalance,
INSERTED.ReturnedDate
VALUES (
@exportHistoryId,
@consumableId,
@returnQuantity,
@returnedByName,
@returnNote,
@previousExportInPeriod,
@nextExportInPeriod,
@previousEndingBalance,
@nextEndingBalance,
@createdBy,
DATEADD(HOUR, 7, SYSUTCDATETIME())
)
`);
await new sql.Request(transaction)
.input('exportHistoryId', sql.Int, exportHistoryId)
.query(`
UPDATE ConsumableExportHistory
SET UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE ExportHistoryId = @exportHistoryId
`);
return {
...(returnResult.recordset?.[0] || {}),
returnedQuantity: returnedQuantity + returnQuantity,
remainingQuantity: Math.max(remainingQuantity - returnQuantity, 0)
};
}
app.get('/api/consumable-borrows', async (req, res) => {
try {
const requesterRole = getRequesterRole(req);
const requesterId = getUserIdFromRequest(req);
const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset';
if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) {
return res.status(401).json({ success: false, message: 'Yêu cầu xác thực người dùng' });
}
const request = pool.request();
if (!canManageRequests) {
request.input('requesterId', sql.Int, requesterId);
}
const result = await request.query(`
SELECT
requests.BorrowRequestId,
requests.ConsumableId,
ISNULL(NULLIF(LTRIM(RTRIM(requests.RequestType)), ''), 'borrow') AS RequestType,
inventory.ConsumableCode,
inventory.ConsumableName,
COALESCE(NULLIF(LTRIM(RTRIM(requests.Unit)), ''), inventory.Unit) AS Unit,
requests.BorrowerName,
requests.BorrowQuantity,
requests.BorrowDate,
requests.RequestStatus,
requests.RequestNote,
requests.RejectReason,
requests.ExportHistoryId,
requests.CreatedBy,
requests.ProcessedBy,
requests.ProcessedByName,
requests.ProcessedDate,
requests.CreatedDate,
requests.UpdatedDate
FROM ConsumableBorrowRequests requests
INNER JOIN ConsumableInventory inventory ON inventory.ConsumableId = requests.ConsumableId
${canManageRequests ? '' : 'WHERE requests.CreatedBy = @requesterId'}
ORDER BY requests.CreatedDate DESC, requests.BorrowRequestId DESC
`);
res.json({ success: true, data: Array.isArray(result.recordset) ? result.recordset : [] });
} catch (err) {
console.error('Create account error:', err.message);
res.status(500).json({ success: false, message: 'Unable to create account' });
}
});
app.post('/api/consumable-borrows', async (req, res) => {
try {
const createdBy = getUserIdFromRequest(req);
const consumableId = Number(req.body?.consumableId);
const borrowQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const borrowDate = parseNullableDate(req.body?.borrowDate) || new Date();
const requestNote = String(req.body?.note || '').trim() || null;
const actorName = await getUserDisplayNameById(createdBy);
const borrowerName = String(actorName || req.body?.borrowerName || '').trim();
if (!Number.isInteger(createdBy) || createdBy <= 0) {
return res.status(401).json({ success: false, message: 'Yêu cầu đăng nhập để tạo đơn mượn' });
}
if (!Number.isInteger(consumableId) || consumableId <= 0) {
return res.status(400).json({ success: false, message: 'Vật tư không hợp lệ' });
}
if (!borrowerName) {
return res.status(400).json({ success: false, message: 'Không xác định được người tạo đơn' });
}
if (borrowQuantity <= 0) {
return res.status(400).json({ success: false, message: 'Số lượng mượn phải lớn hơn 0' });
}
const inventoryResult = await pool.request()
.input('consumableId', sql.Int, consumableId)
.query(`
SELECT TOP 1 ConsumableId, Unit, EndingBalance
FROM ConsumableInventory
WHERE ConsumableId = @consumableId
`);
const inventory = inventoryResult.recordset?.[0];
if (!inventory) {
return res.status(404).json({ success: false, message: 'Không tìm thấy vật tư' });
}
const endingBalance = parseNonNegativeInteger(inventory.EndingBalance, 0);
if (endingBalance <= 0) {
return res.status(400).json({ success: false, message: 'Vật tư đã hết tồn, không thể tạo đơn mượn' });
}
if (borrowQuantity > endingBalance) {
return res.status(400).json({
success: false,
message: `Số lượng mượn (${borrowQuantity}) vượt quá tồn hiện tại (${endingBalance})`
});
}
const insertResult = await pool.request()
.input('consumableId', sql.Int, consumableId)
.input('borrowerName', sql.NVarChar, borrowerName)
.input('borrowQuantity', sql.Int, borrowQuantity)
.input('unit', sql.NVarChar, String(req.body?.unit || inventory.Unit || '').trim() || null)
.input('borrowDate', sql.Date, borrowDate)
.input('requestNote', sql.NVarChar, requestNote)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO ConsumableBorrowRequests (
ConsumableId,
RequestType,
BorrowerName,
BorrowQuantity,
Unit,
BorrowDate,
RequestStatus,
RequestNote,
CreatedBy
)
OUTPUT INSERTED.BorrowRequestId
VALUES (
@consumableId,
'borrow',
@borrowerName,
@borrowQuantity,
@unit,
@borrowDate,
'pending',
@requestNote,
@createdBy
)
`);
res.json({
success: true,
message: 'Tạo đơn mượn vật tư thành công. Đơn đang chờ duyệt.',
data: { borrowRequestId: Number(insertResult.recordset?.[0]?.BorrowRequestId) || null }
});
} catch (err) {
console.error('Create consumable borrow request error:', err.message);
sendInternalError(res, err);
}
});
app.post('/api/consumable-exports/:id/return-request', async (req, res) => {
try {
const exportHistoryId = Number(req.params.id);
const returnQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const requestNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
if (!Number.isInteger(createdBy) || createdBy <= 0) {
return res.status(401).json({ success: false, message: 'Yêu cầu đăng nhập để tạo đơn trả' });
}
if (!Number.isInteger(exportHistoryId) || exportHistoryId <= 0) {
return res.status(400).json({ success: false, message: 'Phiếu xuất không hợp lệ' });
}
if (returnQuantity <= 0) {
return res.status(400).json({ success: false, message: 'Số lượng trả phải lớn hơn 0' });
}
const result = await pool.request()
.input('exportHistoryId', sql.Int, exportHistoryId)
.input('createdBy', sql.Int, createdBy)
.query(`
SELECT TOP 1
exports.ExportHistoryId,
exports.ConsumableId,
exports.ConsumableCode,
exports.ConsumableName,
exports.Unit,
exports.ExportQuantity,
exports.RecipientUserId,
exports.RecipientName,
exports.ProjectName,
COALESCE(NULLIF(LTRIM(RTRIM(users.FullName)), ''), NULLIF(LTRIM(RTRIM(users.Username)), '')) AS CurrentUserName,
ISNULL(returnSummary.ReturnedQuantity, 0) AS ReturnedQuantity,
ISNULL(pendingSummary.PendingQuantity, 0) AS PendingQuantity
FROM ConsumableExportHistory exports
INNER JOIN Users users ON users.UserId = @createdBy
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = exports.ExportHistoryId
) returnSummary
OUTER APPLY (
SELECT SUM(ISNULL(requests.BorrowQuantity, 0)) AS PendingQuantity
FROM ConsumableBorrowRequests requests
WHERE requests.ExportHistoryId = exports.ExportHistoryId
AND LOWER(LTRIM(RTRIM(ISNULL(requests.RequestType, 'borrow')))) = 'return'
AND LOWER(LTRIM(RTRIM(ISNULL(requests.RequestStatus, 'pending')))) = 'pending'
) pendingSummary
WHERE exports.ExportHistoryId = @exportHistoryId
AND NULLIF(LTRIM(RTRIM(exports.ProjectName)), '') IS NULL
AND (
exports.RecipientUserId = @createdBy
OR (
exports.RecipientUserId IS NULL
AND (
LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(users.FullName, ''))))
OR LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(users.Username, ''))))
)
)
)
`);
const exportRow = result.recordset?.[0];
if (!exportRow) {
return res.status(404).json({
success: false,
message: 'Không tìm thấy vật tư đang nhận hoặc bạn không có quyền tạo đơn trả'
});
}
const remainingQuantity = Math.max(
parseNonNegativeInteger(exportRow.ExportQuantity, 0)
- parseNonNegativeInteger(exportRow.ReturnedQuantity, 0),
0
);
const availableQuantity = Math.max(
remainingQuantity - parseNonNegativeInteger(exportRow.PendingQuantity, 0),
0
);
if (returnQuantity > availableQuantity) {
return res.status(400).json({
success: false,
message: `Chỉ còn ${availableQuantity} vật tư có thể tạo đơn trả (đã trừ các đơn đang chờ duyệt)`
});
}
const insertResult = await pool.request()
.input('consumableId', sql.Int, Number(exportRow.ConsumableId))
.input('borrowerName', sql.NVarChar, String(exportRow.CurrentUserName || exportRow.RecipientName || '').trim())
.input('returnQuantity', sql.Int, returnQuantity)
.input('unit', sql.NVarChar, String(exportRow.Unit || '').trim() || null)
.input('requestNote', sql.NVarChar, requestNote)
.input('exportHistoryId', sql.Int, exportHistoryId)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO ConsumableBorrowRequests (
ConsumableId,
RequestType,
BorrowerName,
BorrowQuantity,
Unit,
BorrowDate,
RequestStatus,
RequestNote,
ExportHistoryId,
CreatedBy
)
OUTPUT INSERTED.BorrowRequestId
VALUES (
@consumableId,
'return',
@borrowerName,
@returnQuantity,
@unit,
CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE),
'pending',
@requestNote,
@exportHistoryId,
@createdBy
)
`);
res.json({
success: true,
message: 'Tạo đơn trả vật tư thành công. Đơn đang chờ Asset/Admin duyệt.',
data: { borrowRequestId: Number(insertResult.recordset?.[0]?.BorrowRequestId) || null }
});
} catch (err) {
console.error('Create consumable return request error:', err.message);
sendInternalError(res, err);
}
});
app.post('/api/consumable-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const borrowRequestId = Number(req.params.id);
const action = String(req.body?.action || '').trim().toLowerCase();
const rejectReason = String(req.body?.rejectReason || '').trim() || null;
const processedBy = getUserIdFromRequest(req);
const processedByName = await getUserDisplayNameById(processedBy)
|| req.user?.FullName
|| req.user?.Username
|| 'Unknown';
if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0) {
return res.status(400).json({ success: false, message: 'Mã đơn mượn không hợp lệ' });
}
if (!['approved', 'rejected'].includes(action)) {
return res.status(400).json({ success: false, message: 'Hành động xử lý không hợp lệ' });
}
if (!Number.isInteger(processedBy) || processedBy <= 0) {
return res.status(401).json({ success: false, message: 'Không xác định được người xử lý đơn' });
}
transaction = new sql.Transaction(pool);
await transaction.begin(sql.ISOLATION_LEVEL.SERIALIZABLE);
const targetResult = await new sql.Request(transaction)
.input('borrowRequestId', sql.Int, borrowRequestId)
.query(`
SELECT TOP 1
requests.BorrowRequestId,
requests.ConsumableId,
ISNULL(NULLIF(LTRIM(RTRIM(requests.RequestType)), ''), 'borrow') AS RequestType,
requests.BorrowerName,
requests.BorrowQuantity,
requests.Unit,
requests.RequestStatus,
requests.RequestNote,
requests.ExportHistoryId,
requests.CreatedBy,
inventory.ConsumableCode,
inventory.ConsumableName,
inventory.Unit AS InventoryUnit,
inventory.OpeningBalance,
inventory.ImportInPeriod,
inventory.ExportInPeriod,
inventory.EndingBalance
FROM ConsumableBorrowRequests requests WITH (UPDLOCK, HOLDLOCK)
INNER JOIN ConsumableInventory inventory WITH (UPDLOCK, HOLDLOCK)
ON inventory.ConsumableId = requests.ConsumableId
WHERE requests.BorrowRequestId = @borrowRequestId
`);
const target = targetResult.recordset?.[0];
if (!target) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Không tìm thấy đơn mượn' });
}
if (String(target.RequestStatus || '').trim().toLowerCase() !== 'pending') {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Đơn này đã được xử lý' });
}
const requestType = String(target.RequestType || 'borrow').trim().toLowerCase() === 'return'
? 'return'
: 'borrow';
if (action === 'rejected' && requestType === 'return' && !rejectReason) {
throw createConsumableRequestError('Vui lòng nhập lý do từ chối đơn trả để người tạo đơn được biết');
}
let exportHistoryId = requestType === 'return' ? Number(target.ExportHistoryId) || null : null;
if (action === 'approved') {
const requestQuantity = parseNonNegativeInteger(target.BorrowQuantity, 0);
if (requestQuantity <= 0) {
throw createConsumableRequestError('Số lượng trong đơn không hợp lệ');
}
if (requestType === 'return') {
if (!exportHistoryId) {
throw createConsumableRequestError('Đơn trả không liên kết với phiếu xuất hợp lệ');
}
const returnNote = [
`Duyệt đơn trả #${borrowRequestId}`,
String(target.RequestNote || '').trim()
].filter(Boolean).join(' - ');
await applyConsumableReturn(transaction, {
exportHistoryId,
returnQuantity: requestQuantity,
returnedByName: String(target.BorrowerName || '').trim() || 'Unknown',
returnNote: returnNote || null,
createdBy: Number(target.CreatedBy) || processedBy
});
} else {
const previousExportInPeriod = parseNonNegativeInteger(target.ExportInPeriod, 0);
const previousEndingBalance = parseNonNegativeInteger(target.EndingBalance, 0);
if (requestQuantity > previousEndingBalance) {
throw createConsumableRequestError(
`Không đủ tồn để duyệt đơn. Yêu cầu ${requestQuantity}, hiện còn ${previousEndingBalance}.`
);
}
const nextExportInPeriod = previousExportInPeriod + requestQuantity;
const nextEndingBalance = previousEndingBalance - requestQuantity;
await new sql.Request(transaction)
.input('consumableId', sql.Int, Number(target.ConsumableId))
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.query(`
UPDATE ConsumableInventory
SET ExportInPeriod = @nextExportInPeriod,
EndingBalance = @nextEndingBalance,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE ConsumableId = @consumableId
`);
const exportNote = [
`Duyệt đơn mượn #${borrowRequestId}`,
String(target.RequestNote || '').trim()
].filter(Boolean).join(' - ');
const historyResult = await new sql.Request(transaction)
.input('consumableId', sql.Int, Number(target.ConsumableId))
.input('consumableCode', sql.NVarChar, String(target.ConsumableCode || '').trim())
.input('consumableName', sql.NVarChar, String(target.ConsumableName || '').trim())
.input('unit', sql.NVarChar, String(target.Unit || target.InventoryUnit || '').trim() || null)
.input('exportQuantity', sql.Int, requestQuantity)
.input('recipientUserId', sql.Int, Number(target.CreatedBy) || null)
.input('recipientName', sql.NVarChar, String(target.BorrowerName || '').trim())
.input('exportedByName', sql.NVarChar, processedByName)
.input('exportNote', sql.NVarChar, exportNote || null)
.input('previousExportInPeriod', sql.Int, previousExportInPeriod)
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('previousEndingBalance', sql.Int, previousEndingBalance)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.input('createdBy', sql.Int, processedBy)
.query(`
INSERT INTO ConsumableExportHistory (
ConsumableId,
ConsumableCode,
ConsumableName,
Unit,
ExportQuantity,
RecipientUserId,
RecipientName,
ProjectName,
ExportedByName,
ExportNote,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
CreatedBy,
ExportedDate
)
OUTPUT INSERTED.ExportHistoryId
VALUES (
@consumableId,
@consumableCode,
@consumableName,
@unit,
@exportQuantity,
@recipientUserId,
@recipientName,
NULL,
@exportedByName,
@exportNote,
@previousExportInPeriod,
@nextExportInPeriod,
@previousEndingBalance,
@nextEndingBalance,
@createdBy,
DATEADD(HOUR, 7, SYSUTCDATETIME())
)
`);
exportHistoryId = Number(historyResult.recordset?.[0]?.ExportHistoryId) || null;
}
}
await new sql.Request(transaction)
.input('borrowRequestId', sql.Int, borrowRequestId)
.input('requestStatus', sql.NVarChar, action)
.input('rejectReason', sql.NVarChar, action === 'rejected' ? rejectReason : null)
.input('exportHistoryId', sql.Int, exportHistoryId)
.input('processedBy', sql.Int, processedBy)
.input('processedByName', sql.NVarChar, processedByName)
.query(`
UPDATE ConsumableBorrowRequests
SET RequestStatus = @requestStatus,
RejectReason = @rejectReason,
ExportHistoryId = @exportHistoryId,
ProcessedBy = @processedBy,
ProcessedByName = @processedByName,
ProcessedDate = DATEADD(HOUR, 7, SYSUTCDATETIME()),
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE BorrowRequestId = @borrowRequestId
`);
await transaction.commit();
res.json({
success: true,
message: action === 'approved'
? (requestType === 'return' ? 'Đã duyệt đơn trả và cộng vật tư về kho' : 'Đã duyệt và xuất vật tư cho người mượn')
: (requestType === 'return' ? 'Đã từ chối đơn trả vật tư' : 'Đã từ chối đơn mượn vật tư'),
data: { borrowRequestId, exportHistoryId, requestType }
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (_rollbackErr) {
// Ignore rollback error, respond original error below.
}
}
const statusCode = Number(err.statusCode) || 500;
if (statusCode >= 400 && statusCode < 500) {
return res.status(statusCode).json({ success: false, message: err.message });
}
sendInternalError(res, err);
}
});
app.delete('/api/consumable-borrows/:id', async (req, res) => {
try {
const borrowRequestId = Number(req.params.id);
const requesterRole = getRequesterRole(req);
const requesterId = getUserIdFromRequest(req);
const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset';
if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0) {
return res.status(400).json({ success: false, message: 'Mã đơn mượn không hợp lệ' });
}
const request = pool.request()
.input('borrowRequestId', sql.Int, borrowRequestId);
if (!canManageRequests) {
request.input('requesterId', sql.Int, requesterId || -1);
}
const result = await request.query(`
DELETE FROM ConsumableBorrowRequests
OUTPUT DELETED.BorrowRequestId, DELETED.RequestType
WHERE BorrowRequestId = @borrowRequestId
AND LOWER(LTRIM(RTRIM(RequestStatus))) = 'pending'
${canManageRequests ? '' : 'AND CreatedBy = @requesterId'}
`);
if (!result.recordset?.length) {
return res.status(404).json({ success: false, message: 'Không tìm thấy đơn chờ có thể hủy' });
}
const deletedType = String(result.recordset?.[0]?.RequestType || 'borrow').trim().toLowerCase();
res.json({ success: true, message: deletedType === 'return' ? 'Đã hủy đơn trả' : 'Đã hủy đơn mượn' });
} catch (err) {
console.error('Update account error:', err.message);
res.status(500).json({ success: false, message: 'Unable to update account' });
}
});
app.get('/api/consumables', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT
ConsumableId,
RequestMonth,
ConsumableCode,
ConsumableName,
Model,
Unit,
OpeningBalance,
ImportInPeriod,
ExportInPeriod,
EndingBalance,
ExportReason,
ISNULL(exportSummary.ExportedQuantity, 0) AS ExportedQuantity,
exportSummary.ExportedSummary,
exportSummary.RecipientSummary,
exportSummary.ProjectSummary,
CreatedBy,
CreatedDate,
UpdatedDate
FROM ConsumableInventory ci
OUTER APPLY (
SELECT
SUM(CASE
WHEN ISNULL(historyTotals.ExportQuantity, 0) - ISNULL(returnTotals.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyTotals.ExportQuantity, 0) - ISNULL(returnTotals.ReturnedQuantity, 0)
END) AS ExportedQuantity,
STUFF((
SELECT N', ' + grouped.DestinationName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
FROM (
SELECT
COALESCE(
NULLIF(LTRIM(RTRIM(historyRows.ProjectName)), ''),
NULLIF(LTRIM(RTRIM(historyRows.RecipientName)), ''),
N'Không rõ'
) AS DestinationName,
SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) AS TotalQuantity
FROM ConsumableExportHistory historyRows
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = historyRows.ExportHistoryId
) returnRows
WHERE historyRows.ConsumableId = ci.ConsumableId
GROUP BY COALESCE(
NULLIF(LTRIM(RTRIM(historyRows.ProjectName)), ''),
NULLIF(LTRIM(RTRIM(historyRows.RecipientName)), ''),
N'Không rõ'
)
HAVING SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) > 0
) grouped
ORDER BY grouped.DestinationName
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS ExportedSummary
,
STUFF((
SELECT N', ' + grouped.RecipientName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
FROM (
SELECT
NULLIF(LTRIM(RTRIM(historyRows.RecipientName)), '') AS RecipientName,
SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) AS TotalQuantity
FROM ConsumableExportHistory historyRows
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = historyRows.ExportHistoryId
) returnRows
WHERE historyRows.ConsumableId = ci.ConsumableId
AND NULLIF(LTRIM(RTRIM(historyRows.RecipientName)), '') IS NOT NULL
GROUP BY NULLIF(LTRIM(RTRIM(historyRows.RecipientName)), '')
HAVING SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) > 0
) grouped
ORDER BY grouped.RecipientName
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS RecipientSummary,
STUFF((
SELECT N', ' + grouped.ProjectName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
FROM (
SELECT
NULLIF(LTRIM(RTRIM(historyRows.ProjectName)), '') AS ProjectName,
SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) AS TotalQuantity
FROM ConsumableExportHistory historyRows
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = historyRows.ExportHistoryId
) returnRows
WHERE historyRows.ConsumableId = ci.ConsumableId
AND NULLIF(LTRIM(RTRIM(historyRows.ProjectName)), '') IS NOT NULL
GROUP BY NULLIF(LTRIM(RTRIM(historyRows.ProjectName)), '')
HAVING SUM(CASE
WHEN ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(historyRows.ExportQuantity, 0) - ISNULL(returnRows.ReturnedQuantity, 0)
END) > 0
) grouped
ORDER BY grouped.ProjectName
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS ProjectSummary
FROM ConsumableExportHistory historyTotals
OUTER APPLY (
SELECT SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = historyTotals.ExportHistoryId
) returnTotals
WHERE historyTotals.ConsumableId = ci.ConsumableId
) exportSummary
ORDER BY UpdatedDate DESC, ConsumableName ASC
`);
res.json({ success: true, data: result.recordset.map(accountRecordForResponse) });
} catch (err) {
console.error('Get user accounts error:', err.message);
res.status(500).json({ success: false, message: 'Unable to load accounts' });
}
});
app.post('/api/consumables', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeConsumablePayload(req.body);
const createdBy = getUserIdFromRequest(req);
if (!payload.consumableName) {
return res.status(400).json({ success: false, message: 'Consumable name is required' });
}
if (!payload.consumableCode) {
payload.consumableCode = await generateUniqueManualConsumableCode(payload);
}
const result = await pool.request()
.input('requestMonth', sql.NVarChar, payload.requestMonth)
.input('consumableCode', sql.NVarChar, payload.consumableCode)
.input('consumableName', sql.NVarChar, payload.consumableName)
.input('model', sql.NVarChar, payload.model)
.input('unit', sql.NVarChar, payload.unit)
.input('openingBalance', sql.Int, payload.openingBalance)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('exportReason', sql.NVarChar, payload.exportReason)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO ConsumableInventory (
RequestMonth, ConsumableCode, ConsumableName, Model, Unit,
OpeningBalance, ImportInPeriod, ExportInPeriod, EndingBalance,
ExportReason, CreatedBy
) VALUES (
@requestMonth, @consumableCode, @consumableName, @model, @unit,
@openingBalance, @importInPeriod, @exportInPeriod, @endingBalance,
@exportReason, @createdBy
);
SELECT SCOPE_IDENTITY() AS ConsumableId;
`);
res.json({
success: true,
message: 'Consumable created',
consumableId: result.recordset[0].ConsumableId
});
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Consumable code already exists' });
}
sendInternalError(res, err);
}
});
app.put('/api/consumables/:id', requireAssetOrAdmin, async (req, res) => {
try {
const consumableId = Number(req.params.id);
const payload = normalizeConsumablePayload(req.body);
if (!Number.isInteger(consumableId) || consumableId <= 0) {
return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
}
if (!payload.consumableName) {
return res.status(400).json({ success: false, message: 'Consumable name is required' });
}
if (!payload.consumableCode) {
payload.consumableCode = generateManualConsumableCode(payload);
}
const result = await pool.request()
.input('consumableId', sql.Int, consumableId)
.input('requestMonth', sql.NVarChar, payload.requestMonth)
.input('consumableCode', sql.NVarChar, payload.consumableCode)
.input('consumableName', sql.NVarChar, payload.consumableName)
.input('model', sql.NVarChar, payload.model)
.input('unit', sql.NVarChar, payload.unit)
.input('openingBalance', sql.Int, payload.openingBalance)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('exportReason', sql.NVarChar, payload.exportReason)
.query(`
UPDATE ConsumableInventory
SET RequestMonth = @requestMonth,
ConsumableCode = @consumableCode,
ConsumableName = @consumableName,
Model = @model,
Unit = @unit,
OpeningBalance = @openingBalance,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportReason = @exportReason,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
OUTPUT INSERTED.ConsumableId
WHERE ConsumableId = @consumableId
`);
if (!result.recordset?.length) {
return res.status(404).json({ success: false, message: 'Consumable not found' });
}
res.json({ success: true, message: 'Consumable updated' });
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Consumable code already exists' });
}
sendInternalError(res, err);
}
});
app.delete('/api/consumables/:id', requireAssetOrAdmin, async (req, res) => {
try {
const consumableId = Number(req.params.id);
if (!Number.isInteger(consumableId) || consumableId <= 0) {
return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
}
const result = await pool.request()
.input('consumableId', sql.Int, consumableId)
.query('DELETE FROM ConsumableInventory OUTPUT DELETED.ConsumableId WHERE ConsumableId = @consumableId');
if (!result.recordset?.length) {
return res.status(404).json({ success: false, message: 'Consumable not found' });
}
res.json({ success: true, message: 'Consumable deleted' });
} catch (err) {
console.error('Delete account error:', err.message);
res.status(500).json({ success: false, message: 'Unable to delete account' });
}
});
app.get('/api/consumable-export-history', async (req, res) => {
try {
const limit = Math.min(parsePositiveInteger(req.query.limit, 300), 2000);
const requesterRole = getRequesterRole(req);
const requesterId = getUserIdFromRequest(req);
const canManageAssets = requesterRole === 'admin' || requesterRole === 'asset';
if (!canManageAssets && (!Number.isInteger(requesterId) || requesterId <= 0)) {
return res.status(401).json({ success: false, message: 'Yêu cầu đăng nhập để xem vật tư đang nhận' });
}
const result = await pool.request()
.input('limit', sql.Int, limit)
.input('requesterId', sql.Int, requesterId || -1)
.query(`
SELECT TOP (@limit)
exports.ExportHistoryId,
exports.ConsumableId,
exports.ConsumableCode,
exports.ConsumableName,
exports.Unit,
exports.ExportQuantity,
CASE WHEN NULLIF(LTRIM(RTRIM(exports.ProjectName)), '') IS NOT NULL THEN 'project' ELSE 'user' END AS TargetType,
exports.RecipientUserId,
exports.RecipientName,
exports.ProjectName,
exports.ExportedByName,
exports.ExportNote,
ISNULL(returnSummary.ReturnedQuantity, 0) AS ReturnedQuantity,
CASE
WHEN ISNULL(exports.ExportQuantity, 0) - ISNULL(returnSummary.ReturnedQuantity, 0) < 0 THEN 0
ELSE ISNULL(exports.ExportQuantity, 0) - ISNULL(returnSummary.ReturnedQuantity, 0)
END AS RemainingQuantity,
ISNULL(pendingSummary.PendingReturnQuantity, 0) AS PendingReturnQuantity,
CASE
WHEN ISNULL(exports.ExportQuantity, 0)
- ISNULL(returnSummary.ReturnedQuantity, 0)
- ISNULL(pendingSummary.PendingReturnQuantity, 0) < 0 THEN 0
ELSE ISNULL(exports.ExportQuantity, 0)
- ISNULL(returnSummary.ReturnedQuantity, 0)
- ISNULL(pendingSummary.PendingReturnQuantity, 0)
END AS AvailableReturnRequestQuantity,
CASE
WHEN exports.RecipientUserId = @requesterId THEN CAST(1 AS BIT)
WHEN exports.RecipientUserId IS NULL AND EXISTS (
SELECT 1
FROM Users currentUser
WHERE currentUser.UserId = @requesterId
AND (
LOWER(LTRIM(RTRIM(ISNULL(currentUser.FullName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
OR LOWER(LTRIM(RTRIM(ISNULL(currentUser.Username, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
)
) THEN CAST(1 AS BIT)
ELSE CAST(0 AS BIT)
END AS IsReturnOwner,
CASE
WHEN ISNULL(returnSummary.ReturnedQuantity, 0) <= 0 THEN 'active'
WHEN ISNULL(returnSummary.ReturnedQuantity, 0) >= ISNULL(exports.ExportQuantity, 0) THEN 'returned'
ELSE 'partial'
END AS ReturnStatus,
returnSummary.LastReturnedDate,
latestReturn.ReturnedByName AS LastReturnedByName,
latestReturn.ReturnNote AS LastReturnNote,
exports.PreviousExportInPeriod,
exports.NextExportInPeriod,
exports.PreviousEndingBalance,
exports.NextEndingBalance,
exports.CreatedBy,
exports.ExportedDate,
exports.CreatedDate,
exports.UpdatedDate
FROM ConsumableExportHistory exports
OUTER APPLY (
SELECT
SUM(ISNULL(returns.ReturnQuantity, 0)) AS ReturnedQuantity,
MAX(returns.ReturnedDate) AS LastReturnedDate
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = exports.ExportHistoryId
) returnSummary
OUTER APPLY (
SELECT TOP 1 returns.ReturnedByName, returns.ReturnNote
FROM ConsumableReturnHistory returns
WHERE returns.ExportHistoryId = exports.ExportHistoryId
ORDER BY returns.ReturnedDate DESC, returns.ReturnHistoryId DESC
) latestReturn
OUTER APPLY (
SELECT SUM(ISNULL(requests.BorrowQuantity, 0)) AS PendingReturnQuantity
FROM ConsumableBorrowRequests requests
WHERE requests.ExportHistoryId = exports.ExportHistoryId
AND LOWER(LTRIM(RTRIM(ISNULL(requests.RequestType, 'borrow')))) = 'return'
AND LOWER(LTRIM(RTRIM(ISNULL(requests.RequestStatus, 'pending')))) = 'pending'
) pendingSummary
${canManageAssets ? '' : `WHERE NULLIF(LTRIM(RTRIM(exports.ProjectName)), '') IS NULL
AND (
exports.RecipientUserId = @requesterId
OR (
exports.RecipientUserId IS NULL
AND EXISTS (
SELECT 1
FROM Users currentUser
WHERE currentUser.UserId = @requesterId
AND (
LOWER(LTRIM(RTRIM(ISNULL(currentUser.FullName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
OR LOWER(LTRIM(RTRIM(ISNULL(currentUser.Username, '')))) = LOWER(LTRIM(RTRIM(ISNULL(exports.RecipientName, ''))))
)
)
)
)`}
ORDER BY exports.ExportedDate DESC, exports.ExportHistoryId DESC
`);
res.json({
success: true,
data: Array.isArray(result.recordset) ? result.recordset : []
});
} catch (err) {
console.error('Get user details error:', err.message);
res.status(500).json({ success: false, message: 'Unable to load user details' });
}
});
app.post('/api/consumables/:id/export', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const consumableId = Number(req.params.id);
const exportQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const rawTargetType = String(req.body?.targetType || req.body?.exportTargetType || '').trim().toLowerCase();
const targetType = ['project', 'du_an', 'du-an'].includes(rawTargetType) ? 'project' : 'user';
const requestedRecipientUserId = Number(req.body?.recipientUserId);
let recipientUserId = null;
let recipientName = String(req.body?.recipientName || req.body?.userName || req.body?.exportedTo || '').trim();
const projectName = String(req.body?.projectName || req.body?.project || '').trim();
const exportNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const exportedByName = await getUserDisplayNameById(createdBy) || req.user?.FullName || req.user?.Username || 'Unknown';
const exportedDate = new Date();
if (!Number.isInteger(consumableId) || consumableId <= 0) {
return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
}
if (exportQuantity <= 0) {
return res.status(400).json({ success: false, message: 'So luong xuat phai lon hon 0' });
}
if (targetType === 'project' && !projectName) {
return res.status(400).json({ success: false, message: 'Dự án nhận là bắt buộc' });
}
if (targetType === 'user') {
const recipientResult = await pool.request()
.input(
'recipientUserId',
sql.Int,
Number.isInteger(requestedRecipientUserId) && requestedRecipientUserId > 0
? requestedRecipientUserId
: -1
)
.input('recipientName', sql.NVarChar, recipientName)
.query(`
SELECT TOP 1
UserId,
COALESCE(NULLIF(LTRIM(RTRIM(FullName)), ''), NULLIF(LTRIM(RTRIM(Username)), '')) AS DisplayName
FROM Users
WHERE IsActive = 1
AND (
UserId = @recipientUserId
OR (
@recipientUserId = -1
AND (
LOWER(LTRIM(RTRIM(ISNULL(FullName, '')))) = LOWER(LTRIM(RTRIM(@recipientName)))
OR LOWER(LTRIM(RTRIM(ISNULL(Username, '')))) = LOWER(LTRIM(RTRIM(@recipientName)))
)
)
)
ORDER BY CASE WHEN UserId = @recipientUserId THEN 0 ELSE 1 END, UserId
`);
const recipient = recipientResult.recordset?.[0];
if (!recipient) {
return res.status(400).json({ success: false, message: 'Vui lòng chọn người nhận hợp lệ' });
}
recipientUserId = Number(recipient.UserId);
recipientName = String(recipient.DisplayName || recipientName).trim();
}
transaction = new sql.Transaction(pool);
await transaction.begin();
const consumableResult = await new sql.Request(transaction)
.input('consumableId', sql.Int, consumableId)
.query(`
SELECT TOP 1
ConsumableId,
ConsumableCode,
ConsumableName,
Unit,
OpeningBalance,
ImportInPeriod,
ExportInPeriod,
EndingBalance
FROM ConsumableInventory WITH (UPDLOCK, ROWLOCK)
WHERE ConsumableId = @consumableId
`);
const consumable = consumableResult.recordset?.[0];
if (!consumable) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Consumable not found' });
}
const openingBalance = parseNonNegativeInteger(consumable.OpeningBalance, 0);
const importInPeriod = parseNonNegativeInteger(consumable.ImportInPeriod, 0);
const previousExportInPeriod = parseNonNegativeInteger(consumable.ExportInPeriod, 0);
const storedEndingBalance = parseOptionalNonNegativeInteger(consumable.EndingBalance);
const previousEndingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(openingBalance + importInPeriod - previousExportInPeriod, 0);
if (previousEndingBalance <= 0) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Vat tu da het ton cuoi ky, khong the xuat them' });
}
if (exportQuantity > previousEndingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `So luong xuat (${exportQuantity}) vuot qua ton cuoi ky (${previousEndingBalance})`
});
}
const nextExportInPeriod = previousExportInPeriod + exportQuantity;
const nextEndingBalance = Math.max(previousEndingBalance - exportQuantity, 0);
await new sql.Request(transaction)
.input('consumableId', sql.Int, consumableId)
.input('exportInPeriod', sql.Int, nextExportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance)
.query(`
UPDATE ConsumableInventory
SET ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE ConsumableId = @consumableId
`);
const historyResult = await new sql.Request(transaction)
.input('consumableId', sql.Int, consumableId)
.input('consumableCode', sql.NVarChar, String(consumable.ConsumableCode || '').trim())
.input('consumableName', sql.NVarChar, String(consumable.ConsumableName || '').trim())
.input('unit', sql.NVarChar, String(consumable.Unit || '').trim() || null)
.input('exportQuantity', sql.Int, exportQuantity)
.input('recipientUserId', sql.Int, targetType === 'user' ? recipientUserId : null)
.input('recipientName', sql.NVarChar, targetType === 'user' ? recipientName : null)
.input('projectName', sql.NVarChar, targetType === 'project' ? projectName : null)
.input('exportedByName', sql.NVarChar, exportedByName)
.input('exportNote', sql.NVarChar, exportNote)
.input('previousExportInPeriod', sql.Int, previousExportInPeriod)
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('previousEndingBalance', sql.Int, previousEndingBalance)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.input('createdBy', sql.Int, createdBy)
.input('exportedDate', sql.DateTime, exportedDate)
.query(`
INSERT INTO ConsumableExportHistory (
ConsumableId,
ConsumableCode,
ConsumableName,
Unit,
ExportQuantity,
RecipientUserId,
RecipientName,
ProjectName,
ExportedByName,
ExportNote,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
CreatedBy,
ExportedDate
)
OUTPUT
INSERTED.ExportHistoryId,
INSERTED.ConsumableId,
INSERTED.ConsumableCode,
INSERTED.ConsumableName,
INSERTED.Unit,
INSERTED.ExportQuantity,
INSERTED.RecipientUserId,
INSERTED.RecipientName,
INSERTED.ProjectName,
INSERTED.ExportedByName,
INSERTED.ExportNote,
INSERTED.PreviousExportInPeriod,
INSERTED.NextExportInPeriod,
INSERTED.PreviousEndingBalance,
INSERTED.NextEndingBalance,
INSERTED.CreatedBy,
INSERTED.ExportedDate,
INSERTED.CreatedDate,
INSERTED.UpdatedDate
VALUES (
@consumableId,
@consumableCode,
@consumableName,
@unit,
@exportQuantity,
@recipientUserId,
@recipientName,
@projectName,
@exportedByName,
@exportNote,
@previousExportInPeriod,
@nextExportInPeriod,
@previousEndingBalance,
@nextEndingBalance,
@createdBy,
@exportedDate
)
`);
await transaction.commit();
res.json({
success: true,
message: 'Xuat vat tu tieu hao thanh cong',
data: historyResult.recordset?.[0] || null
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (_rollbackErr) {
// Ignore rollback error, respond original error below.
}
}
sendInternalError(res, err);
}
});
app.post('/api/consumable-exports/:id/return', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const exportHistoryId = Number(req.params.id);
const returnQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const returnNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const returnedByName = await getUserDisplayNameById(createdBy)
|| req.user?.FullName
|| req.user?.Username
|| 'Unknown';
if (!Number.isInteger(exportHistoryId) || exportHistoryId <= 0) {
return res.status(400).json({ success: false, message: 'Phiếu xuất không hợp lệ' });
}
if (returnQuantity <= 0) {
return res.status(400).json({ success: false, message: 'Số lượng trả phải lớn hơn 0' });
}
transaction = new sql.Transaction(pool);
await transaction.begin(sql.ISOLATION_LEVEL.SERIALIZABLE);
const returnData = await applyConsumableReturn(transaction, {
exportHistoryId,
returnQuantity,
returnedByName,
returnNote,
createdBy
});
await transaction.commit();
res.json({
success: true,
message: 'Hoàn trả vật tư về kho thành công',
data: returnData
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (_rollbackErr) {
// Ignore rollback error, respond original error below.
}
}
const statusCode = Number(err.statusCode) || 500;
if (statusCode >= 400 && statusCode < 500) {
return res.status(statusCode).json({ success: false, message: err.message });
}
sendInternalError(res, err);
}
});
app.post('/api/consumables/import', requireAssetOrAdmin, upload.single('file'), async (req, res) => {
let incomingRows = [];
let source = 'rows';
let parseDiagnostics = [];
try {
if (req.file?.buffer) {
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
if (!workbook.SheetNames?.length) {
return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' });
}
const parsed = parseConsumableImportRowsFromWorkbook(workbook);
incomingRows = parsed.rows;
parseDiagnostics = parsed.diagnostics;
source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file';
} else {
incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : [];
}
} catch (err) {
return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` });
}
if (!incomingRows.length) {
return res.status(400).json({
success: false,
message: req.file
? 'Khong tim thay dong vat tu tieu hao hop le trong file Excel.'
: 'Import data is empty',
diagnostics: req.file ? parseDiagnostics : undefined
});
}
const createdBy = getUserIdFromRequest(req);
const normalizedRows = incomingRows
.map((row, rowIndex) => {
const normalized = normalizeConsumablePayload(row);
if (!normalized.consumableCode && normalized.consumableName) {
normalized.consumableCode = generateImportConsumableCodeFromRow(row, rowIndex + 1);
}
return normalized;
})
.filter(row => !isHeaderLikeConsumableImportRow(row))
.filter(row => isMeaningfulImportedConsumableRow(row))
.filter(row => row.consumableCode && row.consumableName);
if (!normalizedRows.length) {
return res.status(400).json({ success: false, message: 'No valid consumable rows found in import data.' });
}
const transaction = new sql.Transaction(pool);
let inserted = 0;
let updated = 0;
try {
await transaction.begin();
for (const row of normalizedRows) {
const mergeResult = await new sql.Request(transaction)
.input('requestMonth', sql.NVarChar, row.requestMonth)
.input('consumableCode', sql.NVarChar, row.consumableCode)
.input('consumableName', sql.NVarChar, row.consumableName)
.input('model', sql.NVarChar, row.model)
.input('unit', sql.NVarChar, row.unit)
.input('openingBalance', sql.Int, row.openingBalance)
.input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance)
.input('exportReason', sql.NVarChar, row.exportReason)
.input('createdBy', sql.Int, createdBy)
.query(`
MERGE ConsumableInventory AS target
USING (SELECT @consumableCode AS ConsumableCode) AS source
ON target.ConsumableCode = source.ConsumableCode
WHEN MATCHED THEN
UPDATE SET
RequestMonth = @requestMonth,
ConsumableName = @consumableName,
Model = @model,
Unit = @unit,
OpeningBalance = @openingBalance,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportReason = @exportReason,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHEN NOT MATCHED THEN
INSERT (
RequestMonth, ConsumableCode, ConsumableName, Model, Unit,
OpeningBalance, ImportInPeriod, ExportInPeriod, EndingBalance,
ExportReason, CreatedBy
)
VALUES (
@requestMonth, @consumableCode, @consumableName, @model, @unit,
@openingBalance, @importInPeriod, @exportInPeriod, @endingBalance,
@exportReason, @createdBy
)
OUTPUT $action AS MergeAction;
`);
const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase();
if (mergeAction === 'INSERT') inserted += 1;
if (mergeAction === 'UPDATE') updated += 1;
}
await transaction.commit();
res.json({
success: true,
message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`,
data: {
source,
totalReceived: incomingRows.length,
processed: normalizedRows.length,
inserted,
updated
}
});
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction is already completed.
}
sendInternalError(res, err);
}
});
app.get('/api/assets', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice,
CASE
WHEN ISNULL(EndingBalance, 0) <= 0 THEN 'exported'
WHEN ISNULL(ExportInPeriod, 0) > 0 THEN 'in_use'
ELSE 'in_stock'
END AS Status,
Notes, CreatedBy, CreatedDate, UpdatedDate
FROM AssetInventory
ORDER BY UpdatedDate DESC, AssetName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
sendInternalError(res, err);
}
});
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 borrowableOnly = ['1', 'true', 'yes'].includes(String(req.query.borrowableOnly || '').trim().toLowerCase());
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)
.input('borrowableOnly', sql.Bit, borrowableOnly ? 1 : 0)
.query(`
;WITH FilteredAssets AS (
SELECT
AssetId,
AssetCode,
AssetName,
Unit,
EndingBalance,
CASE
WHEN ISNULL(EndingBalance, 0) <= 0 THEN 'exported'
WHEN ISNULL(ExportInPeriod, 0) > 0 THEN 'in_use'
ELSE 'in_stock'
END AS Status,
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 (@borrowableOnly = 0 OR ISNULL(EndingBalance, 0) > 0)
AND (
@keyword = ''
OR AssetCode LIKE @keywordLike
OR AssetName LIKE @keywordLike
OR Model LIKE @keywordLike
)
),
OrderedAssets AS (
SELECT
AssetId,
AssetCode,
AssetName,
Unit,
EndingBalance,
Status,
ROW_NUMBER() OVER (
ORDER BY
CodeRank ASC,
NameRank ASC,
UpdatedDate DESC,
AssetName ASC
) AS RowNum
FROM FilteredAssets
)
SELECT AssetId, AssetCode, AssetName, Unit, EndingBalance, Status
FROM OrderedAssets
WHERE RowNum > @offset AND RowNum <= (@offset + @limit)
ORDER BY RowNum;
SELECT COUNT(*) AS TotalCount
FROM AssetInventory
WHERE (@borrowableOnly = 0 OR ISNULL(EndingBalance, 0) > 0)
AND (
@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) {
sendInternalError(res, err);
}
});
app.get('/api/assets/:id', async (req, res) => {
try {
const result = await pool.request()
.input('assetId', sql.Int, req.params.id)
.query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice,
CASE
WHEN ISNULL(EndingBalance, 0) <= 0 THEN 'exported'
WHEN ISNULL(ExportInPeriod, 0) > 0 THEN 'in_use'
ELSE 'in_stock'
END AS Status,
Notes, CreatedBy, CreatedDate, UpdatedDate
FROM AssetInventory
WHERE AssetId = @assetId
`);
if (result.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Asset not found' });
}
res.json({ success: true, data: result.recordset[0] });
} catch (err) {
sendInternalError(res, err);
}
});
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) {
sendInternalError(res, err);
}
});
app.get('/api/asset-damage-disposal-history', requireAssetOrAdmin, async (req, res) => {
try {
const limit = Math.min(parsePositiveInteger(req.query.limit, 300), 2000);
const result = await pool.request()
.input('limit', sql.Int, limit)
.query(`
SELECT TOP (@limit)
DamageHistoryId,
AssetId,
AssetCode,
AssetName,
ActionType,
ActionLabel,
ActionQuantity,
Unit,
PreviousQuantity,
NextQuantity,
PreviousImportInPeriod,
NextImportInPeriod,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
PreviousNewQuantity,
NextNewQuantity,
PreviousUsedQuantity,
NextUsedQuantity,
ActionNote,
CreatedBy,
CreatedByName,
ActionDate,
CreatedDate,
UpdatedDate
FROM AssetDamageDisposalHistory
ORDER BY ActionDate DESC, DamageHistoryId DESC
`);
res.json({
success: true,
data: Array.isArray(result.recordset) ? result.recordset : []
});
} catch (err) {
console.error('Create user error:', err.message);
res.status(500).json({ success: false, message: 'Unable to create user' });
}
});
app.post('/api/assets/:id/damage-disposal', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const assetId = Number(req.params.id);
const actionType = normalizeAssetDamageType(req.body?.actionType || req.body?.reason);
const actionLabel = getAssetDamageTypeLabel(actionType);
const actionQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const actionNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const createdByName = await getUserDisplayNameById(createdBy);
const actionDate = new Date();
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Asset id is invalid' });
}
if (actionQuantity <= 0) {
return res.status(400).json({ success: false, message: 'Số lượng phải lớn hơn 0' });
}
transaction = new sql.Transaction(pool);
await transaction.begin();
const assetResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.query(`
SELECT TOP 1
AssetId,
AssetCode,
AssetName,
Quantity,
ImportInPeriod,
ExportInPeriod,
EndingBalance,
NewQuantity,
UsedQuantity,
Unit
FROM AssetInventory WITH (UPDLOCK, ROWLOCK)
WHERE AssetId = @assetId
`);
const asset = assetResult.recordset?.[0];
if (!asset) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Asset not found' });
}
const previousQuantity = parseNonNegativeInteger(asset.Quantity, 0);
const previousImportInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0);
const previousExportInPeriod = parseNonNegativeInteger(asset.ExportInPeriod, 0);
const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance);
const previousEndingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(previousQuantity + previousImportInPeriod - previousExportInPeriod, 0);
const previousStockBuckets = normalizeAssetStockBuckets(
previousEndingBalance,
parseOptionalNonNegativeInteger(asset.NewQuantity) ?? previousEndingBalance,
parseOptionalNonNegativeInteger(asset.UsedQuantity) ?? 0
);
if (previousEndingBalance <= 0) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Tài sản đã hết tồn cuối kỳ' });
}
if (actionQuantity > previousEndingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Số lượng ${actionLabel.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${previousEndingBalance})`
});
}
let remainingSourceReduction = actionQuantity;
const reduceFromQuantity = Math.min(previousQuantity, remainingSourceReduction);
const nextQuantity = Math.max(previousQuantity - reduceFromQuantity, 0);
remainingSourceReduction -= reduceFromQuantity;
const reduceFromImport = Math.min(previousImportInPeriod, remainingSourceReduction);
const nextImportInPeriod = Math.max(previousImportInPeriod - reduceFromImport, 0);
const nextExportInPeriod = previousExportInPeriod;
const nextEndingBalance = Math.max(nextQuantity + nextImportInPeriod - nextExportInPeriod, 0);
let nextUsedQuantity = previousStockBuckets.usedQuantity;
let nextNewQuantity = previousStockBuckets.newQuantity;
let remainingStockReduction = actionQuantity;
const reduceFromUsed = Math.min(nextUsedQuantity, remainingStockReduction);
nextUsedQuantity -= reduceFromUsed;
remainingStockReduction -= reduceFromUsed;
const reduceFromNew = Math.min(nextNewQuantity, remainingStockReduction);
nextNewQuantity -= reduceFromNew;
const nextStockBuckets = normalizeAssetStockBuckets(nextEndingBalance, nextNewQuantity, nextUsedQuantity);
const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextExportInPeriod);
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('quantity', sql.Int, nextQuantity)
.input('importInPeriod', sql.Int, nextImportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance)
.input('newQuantity', sql.Int, nextStockBuckets.newQuantity)
.input('usedQuantity', sql.Int, nextStockBuckets.usedQuantity)
.input('status', sql.NVarChar, nextStatus)
.query(`
UPDATE AssetInventory
SET Quantity = @quantity,
ImportInPeriod = @importInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE AssetId = @assetId
`);
const historyResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('assetCode', sql.NVarChar, String(asset.AssetCode || '').trim())
.input('assetName', sql.NVarChar, String(asset.AssetName || '').trim())
.input('actionType', sql.NVarChar, actionType)
.input('actionLabel', sql.NVarChar, actionLabel)
.input('actionQuantity', sql.Int, actionQuantity)
.input('unit', sql.NVarChar, String(asset.Unit || '').trim() || null)
.input('previousQuantity', sql.Int, previousQuantity)
.input('nextQuantity', sql.Int, nextQuantity)
.input('previousImportInPeriod', sql.Int, previousImportInPeriod)
.input('nextImportInPeriod', sql.Int, nextImportInPeriod)
.input('previousExportInPeriod', sql.Int, previousExportInPeriod)
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('previousEndingBalance', sql.Int, previousEndingBalance)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.input('previousNewQuantity', sql.Int, previousStockBuckets.newQuantity)
.input('nextNewQuantity', sql.Int, nextStockBuckets.newQuantity)
.input('previousUsedQuantity', sql.Int, previousStockBuckets.usedQuantity)
.input('nextUsedQuantity', sql.Int, nextStockBuckets.usedQuantity)
.input('actionNote', sql.NVarChar, actionNote)
.input('createdBy', sql.Int, createdBy)
.input('createdByName', sql.NVarChar, createdByName)
.input('actionDate', sql.DateTime, actionDate)
.query(`
INSERT INTO AssetDamageDisposalHistory (
AssetId,
AssetCode,
AssetName,
ActionType,
ActionLabel,
ActionQuantity,
Unit,
PreviousQuantity,
NextQuantity,
PreviousImportInPeriod,
NextImportInPeriod,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
PreviousNewQuantity,
NextNewQuantity,
PreviousUsedQuantity,
NextUsedQuantity,
ActionNote,
CreatedBy,
CreatedByName,
ActionDate
)
OUTPUT
INSERTED.DamageHistoryId,
INSERTED.AssetId,
INSERTED.AssetCode,
INSERTED.AssetName,
INSERTED.ActionType,
INSERTED.ActionLabel,
INSERTED.ActionQuantity,
INSERTED.Unit,
INSERTED.PreviousQuantity,
INSERTED.NextQuantity,
INSERTED.PreviousImportInPeriod,
INSERTED.NextImportInPeriod,
INSERTED.PreviousExportInPeriod,
INSERTED.NextExportInPeriod,
INSERTED.PreviousEndingBalance,
INSERTED.NextEndingBalance,
INSERTED.PreviousNewQuantity,
INSERTED.NextNewQuantity,
INSERTED.PreviousUsedQuantity,
INSERTED.NextUsedQuantity,
INSERTED.ActionNote,
INSERTED.CreatedBy,
INSERTED.CreatedByName,
INSERTED.ActionDate,
INSERTED.CreatedDate,
INSERTED.UpdatedDate
VALUES (
@assetId,
@assetCode,
@assetName,
@actionType,
@actionLabel,
@actionQuantity,
@unit,
@previousQuantity,
@nextQuantity,
@previousImportInPeriod,
@nextImportInPeriod,
@previousExportInPeriod,
@nextExportInPeriod,
@previousEndingBalance,
@nextEndingBalance,
@previousNewQuantity,
@nextNewQuantity,
@previousUsedQuantity,
@nextUsedQuantity,
@actionNote,
@createdBy,
@createdByName,
@actionDate
)
`);
await transaction.commit();
res.json({
success: true,
message: `Đã ghi nhận tài sản ${actionLabel.toLowerCase()}`,
data: historyResult.recordset?.[0] || null
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (_rollbackErr) {
// Ignore rollback error, respond original error below.
}
}
sendInternalError(res, err);
}
});
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) || req.user?.FullName || req.user?.Username || '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,
NewQuantity,
UsedQuantity,
Custodian,
Borrower,
Notes
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);
const baseNewQuantity = parseOptionalNonNegativeInteger(asset.NewQuantity);
const baseUsedQuantity = parseOptionalNonNegativeInteger(asset.UsedQuantity);
const stockBuckets = normalizeAssetStockBuckets(
baseEndingBalance,
baseNewQuantity !== null ? baseNewQuantity : baseEndingBalance,
baseUsedQuantity !== null ? baseUsedQuantity : 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);
const borrowFromNew = Math.min(stockBuckets.newQuantity, exportDelta);
const borrowFromUsed = Math.max(exportDelta - borrowFromNew, 0);
const nextNewQuantity = Math.max(stockBuckets.newQuantity - borrowFromNew, 0);
const nextUsedQuantity = Math.max(stockBuckets.usedQuantity - borrowFromUsed, 0);
const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextExportInPeriod);
const existingAssetNotes = String(asset.Notes || '').trim();
const cleanExportNote = String(exportNote || '').trim();
const nextAssetNotes = cleanExportNote
? (existingAssetNotes ? `${existingAssetNotes}\n${cleanExportNote}` : cleanExportNote)
: (existingAssetNotes || null);
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('newQuantity', sql.Int, nextNewQuantity)
.input('usedQuantity', sql.Int, nextUsedQuantity)
.input('status', sql.NVarChar, nextStatus)
.input('exportedBy', sql.NVarChar, exportedByName)
.input('notes', sql.NVarChar, nextAssetNotes)
.query(`
UPDATE AssetInventory
SET Project = @project,
Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = @exportedBy,
Notes = @notes,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
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.
}
}
sendInternalError(res, err);
}
});
app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
if (!payload.model) {
return res.status(400).json({ success: false, message: 'Model is required' });
}
if (!payload.assetCode) {
payload.assetCode = await generateUniqueManualAssetCode(payload);
}
await ensureDepartmentExists(payload.department);
const result = await pool.request()
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('newQuantity', sql.Int, payload.newQuantity)
.input('usedQuantity', sql.Int, payload.usedQuantity)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('borrower', sql.NVarChar, payload.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
) VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
SELECT SCOPE_IDENTITY() AS AssetId;
`);
res.json({ success: true, message: 'Asset created', assetId: result.recordset[0].AssetId });
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Asset code already exists' });
}
sendInternalError(res, err);
}
});
app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
const updatedBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(updatedBy);
if (!payload.assetCode) {
return res.status(400).json({ success: false, message: 'Asset code is required' });
}
if (!payload.model) {
return res.status(400).json({ success: false, message: 'Model is required' });
}
await ensureDepartmentExists(payload.department);
await pool.request()
.input('assetId', sql.Int, req.params.id)
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('newQuantity', sql.Int, payload.newQuantity)
.input('usedQuantity', sql.Int, payload.usedQuantity)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('borrower', sql.NVarChar, payload.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.query(`
UPDATE AssetInventory
SET AssetCode = @assetCode,
AssetName = @assetName,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit,
Department = @department,
Project = @project,
Location = @location,
Custodian = @custodian,
Borrower = @borrower,
ExportedBy = @exportedBy,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
Status = @status,
Notes = @notes,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHERE AssetId = @assetId
`);
res.json({ success: true, message: 'Asset updated' });
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Asset code already exists' });
}
sendInternalError(res, err);
}
});
app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
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.
}
sendInternalError(res, err);
}
});
app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async (req, res) => {
let incomingRows = [];
let source = 'rows';
let parseDiagnostics = [];
try {
if (req.file?.buffer) {
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
if (!workbook.SheetNames?.length) {
return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' });
}
const parsed = parseAssetImportRowsFromWorkbook(workbook);
incomingRows = parsed.rows;
parseDiagnostics = parsed.diagnostics;
source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file';
} else {
incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : [];
}
} catch (err) {
return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` });
}
if (!incomingRows.length) {
if (req.file) {
console.warn('Asset import parse returned 0 rows', {
diagnostics: parseDiagnostics
});
}
return res.status(400).json({
success: false,
message: req.file
? 'Khong tim thay dong du lieu hop le trong file Excel. Vui long kiem tra dong STT va du lieu cot ten/model/ton cuoi ky.'
: 'Import data is empty',
diagnostics: req.file ? parseDiagnostics : undefined
});
}
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
const normalizedRows = incomingRows
.map((row, rowIndex) => {
const normalized = normalizeAssetPayload(row);
const hasOriginalAssetCode = String(normalized.assetCode || '').trim() !== '';
if (!hasOriginalAssetCode && normalized.assetName) {
normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1);
}
normalized.__hasOriginalAssetCode = hasOriginalAssetCode;
return normalized;
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
if (!normalizedRows.length) {
return res.status(400).json({ success: false, message: 'No valid rows found in import data.' });
}
const transaction = new sql.Transaction(pool);
let inserted = 0;
let updated = 0;
try {
await transaction.begin();
for (const row of normalizedRows) {
if (!row.__hasOriginalAssetCode) {
const uniqueImportCode = await ensureUniqueImportAssetCode(transaction, row.assetCode);
row.assetCode = uniqueImportCode;
await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, row.assetCode)
.input('assetName', sql.NVarChar, row.assetName)
.input('model', sql.NVarChar, row.model)
.input('serialNumber', sql.NVarChar, row.serialNumber)
.input('quantity', sql.Int, row.quantity)
.input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance)
.input('newQuantity', sql.Int, row.newQuantity)
.input('usedQuantity', sql.Int, row.usedQuantity)
.input('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project)
.input('location', sql.NVarChar, row.location)
.input('custodian', sql.NVarChar, row.custodian)
.input('borrower', sql.NVarChar, row.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, row.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice)
.input('status', sql.NVarChar, row.status)
.input('notes', sql.NVarChar, row.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
)
VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
`);
inserted += 1;
continue;
}
const mergeResult = await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, row.assetCode)
.input('assetName', sql.NVarChar, row.assetName)
.input('model', sql.NVarChar, row.model)
.input('serialNumber', sql.NVarChar, row.serialNumber)
.input('quantity', sql.Int, row.quantity)
.input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance)
.input('newQuantity', sql.Int, row.newQuantity)
.input('usedQuantity', sql.Int, row.usedQuantity)
.input('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project)
.input('location', sql.NVarChar, row.location)
.input('custodian', sql.NVarChar, row.custodian)
.input('borrower', sql.NVarChar, row.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, row.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice)
.input('status', sql.NVarChar, row.status)
.input('notes', sql.NVarChar, row.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
MERGE AssetInventory AS target
USING (SELECT @assetCode AS AssetCode) AS source
ON target.AssetCode = source.AssetCode
WHEN MATCHED THEN
UPDATE SET
AssetName = @assetName,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit,
Department = @department,
Project = @project,
Location = @location,
Custodian = @custodian,
Borrower = @borrower,
ExportedBy = @exportedBy,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
Status = @status,
Notes = @notes,
UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
WHEN NOT MATCHED THEN
INSERT (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
)
VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
)
OUTPUT $action AS MergeAction;
`);
const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase();
if (mergeAction === 'INSERT') inserted += 1;
if (mergeAction === 'UPDATE') updated += 1;
}
await transaction.commit();
await syncAssetDepartmentsFromInventory();
await syncAssetProjectsFromInventory();
res.json({
success: true,
message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`,
data: {
source,
totalReceived: incomingRows.length,
processed: normalizedRows.length,
inserted,
updated
}
});
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction is already completed.
}
sendInternalError(res, err);
}
});
// ==========================================
// API ROUTES - Database Info
// ==========================================
// Get database information
app.get('/api/database/info', requireAdmin, async (req, res) => {
try {
const tables = await pool.request().query(`
SELECT TABLE_NAME as TableName,
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = t.TABLE_NAME) as ColumnCount
FROM INFORMATION_SCHEMA.TABLES t
WHERE TABLE_SCHEMA = 'dbo'
ORDER BY TABLE_NAME
`);
const users = await pool.request().query('SELECT COUNT(*) as Count FROM Users');
const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications');
const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts');
const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory');
const consumables = await pool.request().query('SELECT COUNT(*) as Count FROM ConsumableInventory');
res.json({
success: true,
database: DB_NAME,
tables: tables.recordset,
statistics: {
users: users.recordset[0].Count,
applications: apps.recordset[0].Count,
accounts: accounts.recordset[0].Count,
assets: assets.recordset[0].Count,
consumables: consumables.recordset[0].Count
}
});
} catch (err) {
console.error('Database info error:', err.message);
res.status(500).json({ success: false, message: 'Unable to load database information' });
}
});
// ==========================================
// Error Handling
// ==========================================
app.use((err, req, res, next) => {
console.error('Unhandled request error:', err.message);
if (err instanceof multer.MulterError || err.message === 'Only .xls and .xlsx files are allowed') {
return res.status(400).json({ success: false, message: err.message });
}
res.status(500).json({ success: false, message: 'Internal server error' });
});
// ==========================================
// Server Startup
// ==========================================
async function startServer() {
try {
await initializeDatabase();
app.listen(PORT, () => {
console.log(`\n========================================`);
console.log(`AccManager Backend Server`);
console.log(`========================================`);
console.log(`[OK] Server running on http://localhost:${PORT}`);
console.log(`[OK] Database: ${DB_NAME}`);
console.log(`\nAPI Endpoints:`);
console.log(` POST /api/auth/login`);
console.log(` GET /api/database/info`);
console.log(` GET /api/users`);
console.log(` GET /api/applications`);
console.log(` GET /api/accounts/user/:userId`);
console.log(` GET /api/assets`);
console.log(`========================================\n`);
});
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...');
if (pool) {
await pool.close();
}
process.exit(0);
});
if (require.main === module) {
startServer();
}
module.exports = {
app,
startServer,
encryptSensitiveValue,
decryptSensitiveValue,
hashSessionToken,
normalizeOptionalHttpUrl
};