trạng thái

This commit is contained in:
2026-05-06 16:36:12 +07:00
parent d88aa39bd6
commit 9f14491562
4 changed files with 389 additions and 43 deletions

View File

@@ -407,6 +407,10 @@ function parseNullableDate(value) {
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';
}
@@ -426,12 +430,61 @@ function normalizeAssetStatus(value) {
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 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 : 0;
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: String(payload.assetCode || '').trim(),
@@ -445,12 +498,14 @@ function normalizeAssetPayload(payload = {}) {
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: normalizeAssetStatus(payload.status),
status,
notes: String(payload.notes || '').trim() || null
};
}
@@ -1659,6 +1714,8 @@ async function createTables() {
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),
@@ -1826,6 +1883,8 @@ async function createTables() {
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;`);
@@ -1881,6 +1940,34 @@ async function createTables() {
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
await pool.request().query(`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)), ''))
@@ -3708,6 +3795,11 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
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
@@ -3736,12 +3828,21 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const endingBalance = Math.max(
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();
@@ -3765,22 +3866,31 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
});
}
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, currentBorrowed + requestQuantity)
.input('endingBalance', sql.Int, Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- (currentBorrowed + requestQuantity),
0
))
.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 = GETDATE()
WHERE AssetId = @assetId
@@ -3806,17 +3916,40 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
), 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, Math.max(quantity + importInPeriod - remainingBorrowed, 0))
.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 = GETDATE()
WHERE AssetId = @assetId
@@ -3931,8 +4064,15 @@ app.get('/api/assets', async (req, res) => {
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, Status, Notes, CreatedBy, CreatedDate, UpdatedDate
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
`);
@@ -4016,8 +4156,15 @@ app.get('/api/assets/:id', async (req, res) => {
.query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy, CreatedDate, UpdatedDate
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
`);
@@ -4104,6 +4251,8 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
ImportInPeriod,
ExportInPeriod,
EndingBalance,
NewQuantity,
UsedQuantity,
Custodian,
Borrower
FROM AssetInventory WITH (UPDLOCK, ROWLOCK)
@@ -4129,6 +4278,13 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
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();
@@ -4150,6 +4306,11 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
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);
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
@@ -4157,6 +4318,9 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
.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)
.query(`
UPDATE AssetInventory
@@ -4164,6 +4328,9 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
@@ -4266,6 +4433,8 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
.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)
@@ -4281,12 +4450,12 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
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,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
@@ -4325,6 +4494,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
.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)
@@ -4346,6 +4517,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit,
Department = @department,
Project = @project,
@@ -4461,6 +4634,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
.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)
@@ -4476,13 +4651,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
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,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
@@ -4500,6 +4675,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
.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)
@@ -4525,6 +4702,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit,
Department = @department,
Project = @project,
@@ -4540,13 +4719,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
WHEN NOT MATCHED THEN
INSERT (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
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,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
)

View File

@@ -95,6 +95,8 @@ BEGIN
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),
@@ -124,6 +126,47 @@ BEGIN
ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;
END
IF COL_LENGTH('dbo.AssetInventory', 'NewQuantity') IS NULL
BEGIN
ALTER TABLE AssetInventory ADD NewQuantity INT NOT NULL CONSTRAINT DF_AssetInventory_NewQuantity DEFAULT(0);
END
IF COL_LENGTH('dbo.AssetInventory', 'UsedQuantity') IS NULL
BEGIN
ALTER TABLE AssetInventory ADD UsedQuantity INT NOT NULL CONSTRAINT DF_AssetInventory_UsedQuantity DEFAULT(0);
END
UPDATE AssetInventory
SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));
UPDATE AssetInventory
SET UsedQuantity = CASE WHEN ISNULL(UsedQuantity, 0) < 0 THEN 0 ELSE ISNULL(UsedQuantity, 0) END;
UPDATE AssetInventory
SET NewQuantity = CASE
WHEN ISNULL(NewQuantity, 0) < 0 THEN 0
ELSE ISNULL(NewQuantity, 0)
END;
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;
-- ===========================================
-- 5. CREATE ASSET DEPARTMENTS TABLE
-- ===========================================

View File

@@ -56,6 +56,7 @@ class AccountManager {
this.initPromise = this.init();
this.pendingAccountAppId = undefined;
this.editingAssetBorrowerEntries = [];
this.editingAssetStockSnapshot = null;
this.pendingBorrowAssetId = undefined;
this.editingAssetDepartmentId = undefined;
this.pendingDeleteAssetDepartmentId = undefined;
@@ -1326,6 +1327,8 @@ class AccountManager {
asset.ImportInPeriod,
asset.ExportInPeriod,
asset.EndingBalance,
asset.NewQuantity,
asset.UsedQuantity,
asset.Department,
asset.Project,
asset.Location,
@@ -1700,15 +1703,12 @@ class AccountManager {
getAssetStatusMeta(status) {
const normalized = String(status || '').toLowerCase();
if (normalized === 'exported') {
return { label: 'Đã xuất', className: 'bg-rose-100 text-rose-700' };
}
if (normalized === 'in_stock') {
return { label: 'Trong kho', className: 'bg-emerald-100 text-emerald-700' };
}
if (normalized === 'maintenance') {
return { label: 'Bảo trì', className: 'bg-amber-100 text-amber-700' };
}
if (normalized === 'disposed') {
return { label: 'Thanh lý', className: 'bg-rose-100 text-rose-700' };
}
return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' };
}
@@ -1977,16 +1977,21 @@ class AccountManager {
buildAssetQuantityMetrics(asset, borrowerEntriesOverride = null) {
const quantity = this.parseNonNegativeInteger(asset?.Quantity ?? asset?.quantity, 0);
const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod ?? asset?.importInPeriod, 0);
const storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod);
const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance);
const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? this.parseBorrowerEntries(borrowerEntriesOverride)
: this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower);
const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => (
sum + this.parseNonNegativeInteger(entry?.quantity, 0)
), 0);
// Borrower entries are the source of truth for exported quantity.
// This keeps UI consistent even when legacy rows have stale stored balances.
const exportInPeriod = borrowerExportInPeriod;
const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
// Prefer stored stock numbers from DB/file to avoid overriding imported balances.
const exportInPeriod = storedExportInPeriod !== null
? storedExportInPeriod
: borrowerExportInPeriod;
const endingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(quantity + importInPeriod - exportInPeriod, 0);
return {
quantity,
@@ -1998,18 +2003,67 @@ class AccountManager {
};
}
computeAssetStatusCode(endingBalance, borrowingQuantity) {
const ending = this.parseNonNegativeInteger(endingBalance, 0);
const borrowing = this.parseNonNegativeInteger(borrowingQuantity, 0);
if (ending <= 0) {
return 'exported';
}
if (borrowing > 0) {
return 'in_use';
}
return 'in_stock';
}
normalizeAssetStockSplit(endingBalance, newQuantityValue, usedQuantityValue) {
const ending = this.parseNonNegativeInteger(endingBalance, 0);
let newQuantity = this.parseNonNegativeInteger(newQuantityValue, ending);
let usedQuantity = this.parseNonNegativeInteger(usedQuantityValue, 0);
const total = newQuantity + usedQuantity;
if (total < ending) {
newQuantity += (ending - total);
} else if (total > ending) {
let overflow = total - ending;
const takeFromNew = Math.min(newQuantity, overflow);
newQuantity -= takeFromNew;
overflow -= takeFromNew;
if (overflow > 0) {
usedQuantity = Math.max(usedQuantity - overflow, 0);
}
}
return {
newQuantity: Math.max(newQuantity, 0),
usedQuantity: Math.max(usedQuantity, 0)
};
}
normalizeAssetComputedFields(asset) {
if (!asset || typeof asset !== 'object') {
return asset;
}
const metrics = this.buildAssetQuantityMetrics(asset);
const stockSplit = this.normalizeAssetStockSplit(
metrics.endingBalance,
asset?.NewQuantity ?? asset?.newQuantity,
asset?.UsedQuantity ?? asset?.usedQuantity
);
const status = this.computeAssetStatusCode(metrics.endingBalance, metrics.exportInPeriod);
return {
...asset,
Quantity: metrics.quantity,
ImportInPeriod: metrics.importInPeriod,
ExportInPeriod: metrics.exportInPeriod,
EndingBalance: metrics.endingBalance,
NewQuantity: stockSplit.newQuantity,
UsedQuantity: stockSplit.usedQuantity,
Status: status,
Borrower: this.formatBorrowerEntries(metrics.borrowerEntries, '; ') || null
};
}
@@ -2019,6 +2073,7 @@ class AccountManager {
const importInput = document.getElementById('assetImportInPeriodInput');
const exportInput = document.getElementById('assetExportInPeriodInput');
const endingInput = document.getElementById('assetEndingBalanceInput');
const statusInput = document.getElementById('assetStatusInput');
if (!quantityInput || !importInput) {
return;
@@ -2035,6 +2090,12 @@ class AccountManager {
if (endingInput) {
endingInput.value = String(endingBalance);
}
if (statusInput) {
const statusCode = this.computeAssetStatusCode(endingBalance, exportInPeriod);
const statusMeta = this.getAssetStatusMeta(statusCode);
statusInput.value = statusMeta.label;
statusInput.dataset.statusCode = statusCode;
}
}
setupAssetStockListeners() {
@@ -3510,8 +3571,7 @@ class AccountManager {
<option value="">Tất cả</option>
<option value="in_use">Đang sử dụng</option>
<option value="in_stock">Trong kho</option>
<option value="maintenance">Bảo trì</option>
<option value="disposed">Thanh lý</option>
<option value="exported">Đã xuất</option>
</select>
</div>
<div class="flex items-center gap-1.5 flex-1">
@@ -3527,7 +3587,7 @@ class AccountManager {
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
${pageInfo.data.length > 0 ? `
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse" style="min-width: 2400px; border-collapse: separate; border-spacing: 0;">
<table class="w-full text-left border-collapse" style="min-width: 2720px; border-collapse: separate; border-spacing: 0;">
<thead class="sticky top-0 z-50 bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-4 py-2.5 text-center">
@@ -3547,6 +3607,9 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Dự án</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người phụ trách</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500" style="width: 132px; min-width: 132px; white-space: nowrap;">Trạng thái</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL hàng mới</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL đã qua sử dụng</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL đang mượn</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Vị trí</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày mua</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500" style="width: 240px; min-width: 240px;">Người mượn</th>
@@ -3583,6 +3646,9 @@ class AccountManager {
<td class="px-4 py-3 text-sm" style="width: 132px; min-width: 132px; white-space: nowrap;">
<span class="inline-block px-2 py-1 rounded text-xs font-semibold ${statusMeta.className}" style="display: inline-block; white-space: nowrap; word-break: keep-all; overflow-wrap: normal;">${statusMeta.label}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.NewQuantity ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.UsedQuantity ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportInPeriod ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.Location || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.PurchaseDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal" style="width: 240px; min-width: 240px;">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
@@ -3667,6 +3733,9 @@ class AccountManager {
<td class="px-4 py-3 text-sm" style="width: 132px; min-width: 132px; white-space: nowrap;">
<span class="inline-block px-2 py-1 rounded text-xs font-semibold ${statusMeta.className}" style="display: inline-block; white-space: nowrap; word-break: keep-all; overflow-wrap: normal;">${statusMeta.label}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.NewQuantity ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.UsedQuantity ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportInPeriod ?? 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.Location || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.PurchaseDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal" style="width: 240px; min-width: 240px;">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
@@ -3895,6 +3964,9 @@ class AccountManager {
['Nhập trong kỳ', asset?.ImportInPeriod ?? 0],
['Xuất trong kỳ', asset?.ExportInPeriod ?? 0],
['Tồn cuối kỳ', asset?.EndingBalance ?? 0],
['SL hàng mới', asset?.NewQuantity ?? 0],
['SL đã qua sử dụng', asset?.UsedQuantity ?? 0],
['SL đang mượn', asset?.ExportInPeriod ?? 0],
['Phòng ban', asset?.Department],
['Dự án', asset?.Project],
['Vị trí', asset?.Location],
@@ -3922,16 +3994,36 @@ class AccountManager {
populateAssetForm(asset) {
const sourceAsset = asset || {};
const borrowerEntries = this.parseBorrowerEntries(sourceAsset?.Borrower);
const metrics = this.buildAssetQuantityMetrics(sourceAsset, borrowerEntries);
const stockSplit = this.normalizeAssetStockSplit(
metrics.endingBalance,
sourceAsset?.NewQuantity ?? metrics.endingBalance,
sourceAsset?.UsedQuantity ?? 0
);
const statusCode = sourceAsset?.AssetId
? this.computeAssetStatusCode(metrics.endingBalance, metrics.exportInPeriod)
: 'in_stock';
const statusMeta = this.getAssetStatusMeta(statusCode);
this.editingAssetBorrowerEntries = borrowerEntries;
this.editingAssetStockSnapshot = {
endingBalance: metrics.endingBalance,
newQuantity: stockSplit.newQuantity,
usedQuantity: stockSplit.usedQuantity
};
this.clearAssetFormValidation();
document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || '';
document.getElementById('assetNameInput').value = sourceAsset?.AssetName || '';
document.getElementById('assetStatusInput').value = String(sourceAsset?.Status || 'in_use').toLowerCase();
const statusInput = document.getElementById('assetStatusInput');
if (statusInput) {
statusInput.value = statusMeta.label;
statusInput.dataset.statusCode = statusCode;
}
document.getElementById('assetModelInput').value = sourceAsset?.Model || '';
document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || '';
document.getElementById('assetQuantityInput').value = this.parseNonNegativeInteger(sourceAsset?.Quantity, 0);
document.getElementById('assetImportInPeriodInput').value = this.parseNonNegativeInteger(sourceAsset?.ImportInPeriod, 0);
document.getElementById('assetQuantityInput').value = metrics.quantity;
document.getElementById('assetImportInPeriodInput').value = metrics.importInPeriod;
document.getElementById('assetUnitInput').value = sourceAsset?.Unit || '';
this.refreshAssetDepartmentOptions(sourceAsset?.Department || '');
this.refreshAssetProjectOptions(sourceAsset?.Project || '');
@@ -3962,6 +4054,7 @@ class AccountManager {
}
if (this.editingAssetId === undefined) {
this.editingAssetStockSnapshot = null;
this.populateAssetForm(null);
}
@@ -4087,19 +4180,36 @@ class AccountManager {
const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null;
const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
const endingBalance = endingBalanceInput !== null ? endingBalanceInput : computedEndingBalance;
const status = this.computeAssetStatusCode(endingBalance, exportInPeriod);
const previousSnapshot = this.editingAssetStockSnapshot;
let nextNewQuantity = endingBalance;
let nextUsedQuantity = 0;
if (previousSnapshot) {
const previousEnding = this.parseNonNegativeInteger(previousSnapshot.endingBalance, 0);
const previousNew = this.parseNonNegativeInteger(previousSnapshot.newQuantity, previousEnding);
const previousUsed = this.parseNonNegativeInteger(previousSnapshot.usedQuantity, 0);
const deltaEnding = endingBalance - previousEnding;
const tentativeNew = Math.max(previousNew + deltaEnding, 0);
const normalized = this.normalizeAssetStockSplit(endingBalance, tentativeNew, previousUsed);
nextNewQuantity = normalized.newQuantity;
nextUsedQuantity = normalized.usedQuantity;
}
const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim();
return {
assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '',
assetName: document.getElementById('assetNameInput')?.value?.trim() || '',
status: document.getElementById('assetStatusInput')?.value || 'in_use',
status,
model: document.getElementById('assetModelInput')?.value?.trim() || '',
serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '',
quantity,
importInPeriod,
exportInPeriod,
endingBalance,
newQuantity: nextNewQuantity,
usedQuantity: nextUsedQuantity,
unit: document.getElementById('assetUnitInput')?.value?.trim() || '',
department: document.getElementById('assetDepartmentInput')?.value?.trim() || '',
project: document.getElementById('assetProjectInput')?.value?.trim() || '',
@@ -4124,6 +4234,13 @@ class AccountManager {
const resolvedBaseEndingBalance = baseEndingBalance !== null
? baseEndingBalance
: Math.max(quantity + importInPeriod - baseExportInPeriod, 0);
const baseNewQuantity = this.parseOptionalNonNegativeInteger(asset?.NewQuantity);
const baseUsedQuantity = this.parseOptionalNonNegativeInteger(asset?.UsedQuantity);
const resolvedStockSplit = this.normalizeAssetStockSplit(
resolvedBaseEndingBalance,
baseNewQuantity !== null ? baseNewQuantity : resolvedBaseEndingBalance,
baseUsedQuantity !== null ? baseUsedQuantity : 0
);
const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? borrowerEntriesOverride
: this.parseBorrowerEntries(asset?.Borrower);
@@ -4131,6 +4248,8 @@ class AccountManager {
let exportInPeriod = baseExportInPeriod;
let endingBalance = resolvedBaseEndingBalance;
let newQuantity = resolvedStockSplit.newQuantity;
let usedQuantity = resolvedStockSplit.usedQuantity;
if (Array.isArray(borrowerEntriesOverride)) {
const existingBorrowerEntries = this.parseBorrowerEntries(asset?.Borrower);
const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => (
@@ -4142,6 +4261,13 @@ class AccountManager {
const exportDelta = nextBorrowerExport - previousBorrowerExport;
exportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0);
endingBalance = Math.max(resolvedBaseEndingBalance - exportDelta, 0);
const borrowFromNew = Math.min(newQuantity, Math.max(exportDelta, 0));
const borrowFromUsed = Math.max(Math.max(exportDelta, 0) - borrowFromNew, 0);
newQuantity = Math.max(newQuantity - borrowFromNew, 0);
usedQuantity = Math.max(usedQuantity - borrowFromUsed, 0);
const normalizedSplit = this.normalizeAssetStockSplit(endingBalance, newQuantity, usedQuantity);
newQuantity = normalizedSplit.newQuantity;
usedQuantity = normalizedSplit.usedQuantity;
}
const rawPrice = asset?.PurchasePrice;
@@ -4152,13 +4278,15 @@ class AccountManager {
return {
assetCode: String(asset?.AssetCode || '').trim(),
assetName: String(asset?.AssetName || '').trim(),
status: String(asset?.Status || 'in_use'),
status: this.computeAssetStatusCode(endingBalance, exportInPeriod),
model: String(asset?.Model || '').trim(),
serialNumber: String(asset?.SerialNumber || '').trim(),
quantity,
importInPeriod,
exportInPeriod,
endingBalance,
newQuantity,
usedQuantity,
unit: String(asset?.Unit || '').trim(),
department: String(asset?.Department || '').trim(),
project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(),
@@ -4398,6 +4526,7 @@ class AccountManager {
}
this.editingAssetId = undefined;
this.editingAssetStockSnapshot = null;
this.notifySuccess(isEdit ? 'Cập nhật tài sản thành công' : 'Thêm tài sản thành công');
this.closeModals();
await this.refreshAssetsUI();

View File

@@ -222,12 +222,7 @@
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Trạng thái</label>
<select id="assetStatusInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
<option value="in_use">Đang sử dụng</option>
<option value="in_stock">Trong kho</option>
<option value="maintenance">Bảo trì</option>
<option value="disposed">Thanh lý</option>
</select>
<input type="text" id="assetStatusInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly value="Trong kho">
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Model</label>