diff --git a/backend/server.js b/backend/server.js index 6ea0036..43d4afd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 ) diff --git a/database/setup.sql b/database/setup.sql index 18a6ae3..254d691 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -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 -- =========================================== diff --git a/public/js/app.js b/public/js/app.js index 9e2af35..33a0a90 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 { - - +
@@ -3527,7 +3587,7 @@ class AccountManager {
${pageInfo.data.length > 0 ? `
- +
+ + + @@ -3583,6 +3646,9 @@ class AccountManager { + + + @@ -3667,6 +3733,9 @@ class AccountManager { + + + @@ -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(); diff --git a/public/modals.html b/public/modals.html index 2469cc7..4bae62f 100644 --- a/public/modals.html +++ b/public/modals.html @@ -222,12 +222,7 @@
- +
@@ -3547,6 +3607,9 @@ class AccountManager { Dự án Người phụ trách Trạng tháiSL hàng mớiSL đã qua sử dụngSL đang mượn Vị trí Ngày mua Người mượn ${statusMeta.label} ${asset.NewQuantity ?? 0}${asset.UsedQuantity ?? 0}${asset.ExportInPeriod ?? 0} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)} ${statusMeta.label} ${asset.NewQuantity ?? 0}${asset.UsedQuantity ?? 0}${asset.ExportInPeriod ?? 0} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)}