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) { function normalizeAssetStatus(value) {
const normalized = String(value || '').trim().toLowerCase(); 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)) { if (['in_use', 'in use', 'dang su dung', 'active'].includes(normalized)) {
return 'in_use'; return 'in_use';
} }
@@ -426,12 +430,61 @@ function normalizeAssetStatus(value) {
return 'in_use'; 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 = {}) { function normalizeAssetPayload(payload = {}) {
const quantity = parseNonNegativeIntegerOrFallback(payload.quantity, 0); const quantity = parseNonNegativeIntegerOrFallback(payload.quantity, 0);
const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0); const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0);
const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0); const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0);
const providedEndingBalance = parseOptionalNonNegativeInteger(payload.endingBalance); 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 { return {
assetCode: String(payload.assetCode || '').trim(), assetCode: String(payload.assetCode || '').trim(),
@@ -445,12 +498,14 @@ function normalizeAssetPayload(payload = {}) {
importInPeriod, importInPeriod,
exportInPeriod, exportInPeriod,
endingBalance, endingBalance,
newQuantity: stockBuckets.newQuantity,
usedQuantity: stockBuckets.usedQuantity,
location: String(payload.location || '').trim() || null, location: String(payload.location || '').trim() || null,
custodian: String(payload.custodian || '').trim() || null, custodian: String(payload.custodian || '').trim() || null,
borrower: String(payload.borrower || '').trim() || null, borrower: String(payload.borrower || '').trim() || null,
purchaseDate: parseNullableDate(payload.purchaseDate), purchaseDate: parseNullableDate(payload.purchaseDate),
purchasePrice: parseNullableDecimal(payload.purchasePrice), purchasePrice: parseNullableDecimal(payload.purchasePrice),
status: normalizeAssetStatus(payload.status), status,
notes: String(payload.notes || '').trim() || null notes: String(payload.notes || '').trim() || null
}; };
} }
@@ -1659,6 +1714,8 @@ async function createTables() {
ImportInPeriod INT NOT NULL DEFAULT 0, ImportInPeriod INT NOT NULL DEFAULT 0,
ExportInPeriod INT NOT NULL DEFAULT 0, ExportInPeriod INT NOT NULL DEFAULT 0,
EndingBalance 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), Unit NVARCHAR(50),
Department NVARCHAR(100), Department NVARCHAR(100),
Project NVARCHAR(150), 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','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','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','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','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','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.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 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 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 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(` await pool.request().query(`
UPDATE ai UPDATE ai
SET ai.ExportedBy = COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), NULLIF(LTRIM(RTRIM(u.Username)), '')) 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.AssetName,
ai.Quantity, ai.Quantity,
ai.ImportInPeriod, ai.ImportInPeriod,
ai.ExportInPeriod,
ai.EndingBalance,
ai.NewQuantity,
ai.UsedQuantity,
ai.Status,
ai.Borrower, ai.Borrower,
ai.Unit AS AssetUnit ai.Unit AS AssetUnit
FROM AssetBorrowRequests br 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) => ( const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0) sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0); ), 0);
const endingBalance = Math.max( const derivedEndingBalance = Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0) parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0) + parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- currentBorrowed, - currentBorrowed,
0 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) { if (requestQuantity > endingBalance) {
await transaction.rollback(); 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) await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId) .input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary) .input('borrower', sql.NVarChar, mergedBorrowerSummary)
.input('exportInPeriod', sql.Int, currentBorrowed + requestQuantity) .input('exportInPeriod', sql.Int, nextBorrowingQuantity)
.input('endingBalance', sql.Int, Math.max( .input('endingBalance', sql.Int, nextEndingBalance)
parseNonNegativeInteger(targetRequest.Quantity, 0) .input('newQuantity', sql.Int, nextNewQuantity)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0) .input('usedQuantity', sql.Int, nextUsedQuantity)
- (currentBorrowed + requestQuantity), .input('status', sql.NVarChar, nextStatus)
0
))
.input('exportedBy', sql.NVarChar, processorName || null) .input('exportedBy', sql.NVarChar, processorName || null)
.query(` .query(`
UPDATE AssetInventory UPDATE AssetInventory
SET Borrower = @borrower, SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod, ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance, EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = @exportedBy, ExportedBy = @exportedBy,
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId WHERE AssetId = @assetId
@@ -3806,17 +3916,40 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
), 0); ), 0);
const quantity = parseNonNegativeInteger(targetRequest.Quantity, 0); const quantity = parseNonNegativeInteger(targetRequest.Quantity, 0);
const importInPeriod = parseNonNegativeInteger(targetRequest.ImportInPeriod, 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) await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId) .input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, borrowerSummary) .input('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, remainingBorrowed) .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) .input('exportedBy', sql.NVarChar, processorName || null)
.query(` .query(`
UPDATE AssetInventory UPDATE AssetInventory
SET Borrower = @borrower, SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod, ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance, EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END, ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId WHERE AssetId = @assetId
@@ -3931,8 +4064,15 @@ app.get('/api/assets', async (req, res) => {
const result = await pool.request().query(` const result = await pool.request().query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber, SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, 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 FROM AssetInventory
ORDER BY UpdatedDate DESC, AssetName ASC ORDER BY UpdatedDate DESC, AssetName ASC
`); `);
@@ -4016,8 +4156,15 @@ app.get('/api/assets/:id', async (req, res) => {
.query(` .query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber, SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, 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 FROM AssetInventory
WHERE AssetId = @assetId WHERE AssetId = @assetId
`); `);
@@ -4104,6 +4251,8 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
ImportInPeriod, ImportInPeriod,
ExportInPeriod, ExportInPeriod,
EndingBalance, EndingBalance,
NewQuantity,
UsedQuantity,
Custodian, Custodian,
Borrower Borrower
FROM AssetInventory WITH (UPDLOCK, ROWLOCK) FROM AssetInventory WITH (UPDLOCK, ROWLOCK)
@@ -4129,6 +4278,13 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
const baseEndingBalance = storedEndingBalance !== null const baseEndingBalance = storedEndingBalance !== null
? storedEndingBalance ? storedEndingBalance
: Math.max(quantity + importInPeriod - baseExportInPeriod, 0); : 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) { if (baseEndingBalance <= 0) {
await transaction.rollback(); await transaction.rollback();
@@ -4150,6 +4306,11 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
const exportDelta = nextBorrowerExport - previousBorrowerExport; const exportDelta = nextBorrowerExport - previousBorrowerExport;
const nextExportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0); const nextExportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0);
const nextEndingBalance = Math.max(baseEndingBalance - 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) await new sql.Request(transaction)
.input('assetId', sql.Int, assetId) .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('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, nextExportInPeriod) .input('exportInPeriod', sql.Int, nextExportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance) .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('exportedBy', sql.NVarChar, exportedByName)
.query(` .query(`
UPDATE AssetInventory UPDATE AssetInventory
@@ -4164,6 +4328,9 @@ app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
Borrower = @borrower, Borrower = @borrower,
ExportInPeriod = @exportInPeriod, ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance, EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
ExportedBy = @exportedBy, ExportedBy = @exportedBy,
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId WHERE AssetId = @assetId
@@ -4266,6 +4433,8 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
.input('importInPeriod', sql.Int, payload.importInPeriod) .input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod) .input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance) .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('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department) .input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project) .input('project', sql.NVarChar, payload.project)
@@ -4281,12 +4450,12 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
.query(` .query(`
INSERT INTO AssetInventory ( INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
) VALUES ( ) VALUES (
@assetCode, @assetName, @model, @serialNumber, @assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy, @unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy @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('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod) .input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance) .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('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department) .input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project) .input('project', sql.NVarChar, payload.project)
@@ -4346,6 +4517,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
ImportInPeriod = @importInPeriod, ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod, ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance, EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit, Unit = @unit,
Department = @department, Department = @department,
Project = @project, Project = @project,
@@ -4461,6 +4634,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
.input('importInPeriod', sql.Int, row.importInPeriod) .input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod) .input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance) .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('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department) .input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project) .input('project', sql.NVarChar, row.project)
@@ -4476,13 +4651,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
.query(` .query(`
INSERT INTO AssetInventory ( INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
) )
VALUES ( VALUES (
@assetCode, @assetName, @model, @serialNumber, @assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy, @unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy @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('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod) .input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance) .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('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department) .input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project) .input('project', sql.NVarChar, row.project)
@@ -4525,6 +4702,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
ImportInPeriod = @importInPeriod, ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod, ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance, EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Unit = @unit, Unit = @unit,
Department = @department, Department = @department,
Project = @project, Project = @project,
@@ -4540,13 +4719,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
WHEN NOT MATCHED THEN WHEN NOT MATCHED THEN
INSERT ( INSERT (
AssetCode, AssetName, Model, SerialNumber, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, NewQuantity, UsedQuantity,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
) )
VALUES ( VALUES (
@assetCode, @assetName, @model, @serialNumber, @assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance, @quantity, @importInPeriod, @exportInPeriod, @endingBalance, @newQuantity, @usedQuantity,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy, @unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy @purchaseDate, @purchasePrice, @status, @notes, @createdBy
) )

View File

@@ -95,6 +95,8 @@ BEGIN
ImportInPeriod INT NOT NULL DEFAULT 0, ImportInPeriod INT NOT NULL DEFAULT 0,
ExportInPeriod INT NOT NULL DEFAULT 0, ExportInPeriod INT NOT NULL DEFAULT 0,
EndingBalance 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), Unit NVARCHAR(50),
Department NVARCHAR(100), Department NVARCHAR(100),
Project NVARCHAR(150), Project NVARCHAR(150),
@@ -124,6 +126,47 @@ BEGIN
ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL; ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;
END 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 -- 5. CREATE ASSET DEPARTMENTS TABLE
-- =========================================== -- ===========================================

View File

@@ -56,6 +56,7 @@ class AccountManager {
this.initPromise = this.init(); this.initPromise = this.init();
this.pendingAccountAppId = undefined; this.pendingAccountAppId = undefined;
this.editingAssetBorrowerEntries = []; this.editingAssetBorrowerEntries = [];
this.editingAssetStockSnapshot = null;
this.pendingBorrowAssetId = undefined; this.pendingBorrowAssetId = undefined;
this.editingAssetDepartmentId = undefined; this.editingAssetDepartmentId = undefined;
this.pendingDeleteAssetDepartmentId = undefined; this.pendingDeleteAssetDepartmentId = undefined;
@@ -1326,6 +1327,8 @@ class AccountManager {
asset.ImportInPeriod, asset.ImportInPeriod,
asset.ExportInPeriod, asset.ExportInPeriod,
asset.EndingBalance, asset.EndingBalance,
asset.NewQuantity,
asset.UsedQuantity,
asset.Department, asset.Department,
asset.Project, asset.Project,
asset.Location, asset.Location,
@@ -1700,15 +1703,12 @@ class AccountManager {
getAssetStatusMeta(status) { getAssetStatusMeta(status) {
const normalized = String(status || '').toLowerCase(); const normalized = String(status || '').toLowerCase();
if (normalized === 'exported') {
return { label: 'Đã xuất', className: 'bg-rose-100 text-rose-700' };
}
if (normalized === 'in_stock') { if (normalized === 'in_stock') {
return { label: 'Trong kho', className: 'bg-emerald-100 text-emerald-700' }; 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' }; return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' };
} }
@@ -1977,16 +1977,21 @@ class AccountManager {
buildAssetQuantityMetrics(asset, borrowerEntriesOverride = null) { buildAssetQuantityMetrics(asset, borrowerEntriesOverride = null) {
const quantity = this.parseNonNegativeInteger(asset?.Quantity ?? asset?.quantity, 0); const quantity = this.parseNonNegativeInteger(asset?.Quantity ?? asset?.quantity, 0);
const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod ?? asset?.importInPeriod, 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) const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? this.parseBorrowerEntries(borrowerEntriesOverride) ? this.parseBorrowerEntries(borrowerEntriesOverride)
: this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower); : this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower);
const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => ( const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => (
sum + this.parseNonNegativeInteger(entry?.quantity, 0) sum + this.parseNonNegativeInteger(entry?.quantity, 0)
), 0); ), 0);
// Borrower entries are the source of truth for exported quantity. // Prefer stored stock numbers from DB/file to avoid overriding imported balances.
// This keeps UI consistent even when legacy rows have stale stored balances. const exportInPeriod = storedExportInPeriod !== null
const exportInPeriod = borrowerExportInPeriod; ? storedExportInPeriod
const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); : borrowerExportInPeriod;
const endingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(quantity + importInPeriod - exportInPeriod, 0);
return { return {
quantity, 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) { normalizeAssetComputedFields(asset) {
if (!asset || typeof asset !== 'object') { if (!asset || typeof asset !== 'object') {
return asset; return asset;
} }
const metrics = this.buildAssetQuantityMetrics(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 { return {
...asset, ...asset,
Quantity: metrics.quantity, Quantity: metrics.quantity,
ImportInPeriod: metrics.importInPeriod, ImportInPeriod: metrics.importInPeriod,
ExportInPeriod: metrics.exportInPeriod, ExportInPeriod: metrics.exportInPeriod,
EndingBalance: metrics.endingBalance, EndingBalance: metrics.endingBalance,
NewQuantity: stockSplit.newQuantity,
UsedQuantity: stockSplit.usedQuantity,
Status: status,
Borrower: this.formatBorrowerEntries(metrics.borrowerEntries, '; ') || null Borrower: this.formatBorrowerEntries(metrics.borrowerEntries, '; ') || null
}; };
} }
@@ -2019,6 +2073,7 @@ class AccountManager {
const importInput = document.getElementById('assetImportInPeriodInput'); const importInput = document.getElementById('assetImportInPeriodInput');
const exportInput = document.getElementById('assetExportInPeriodInput'); const exportInput = document.getElementById('assetExportInPeriodInput');
const endingInput = document.getElementById('assetEndingBalanceInput'); const endingInput = document.getElementById('assetEndingBalanceInput');
const statusInput = document.getElementById('assetStatusInput');
if (!quantityInput || !importInput) { if (!quantityInput || !importInput) {
return; return;
@@ -2035,6 +2090,12 @@ class AccountManager {
if (endingInput) { if (endingInput) {
endingInput.value = String(endingBalance); 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() { setupAssetStockListeners() {
@@ -3510,8 +3571,7 @@ class AccountManager {
<option value="">Tất cả</option> <option value="">Tất cả</option>
<option value="in_use">Đang sử dụng</option> <option value="in_use">Đang sử dụng</option>
<option value="in_stock">Trong kho</option> <option value="in_stock">Trong kho</option>
<option value="maintenance">Bảo trì</option> <option value="exported">Đã xuất</option>
<option value="disposed">Thanh lý</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-1.5 flex-1"> <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"> <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 ? ` ${pageInfo.data.length > 0 ? `
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1"> <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"> <thead class="sticky top-0 z-50 bg-slate-50 border-b border-slate-200">
<tr> <tr>
<th class="px-4 py-2.5 text-center"> <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">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">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" 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">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">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> <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;"> <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> <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>
<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">${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-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> <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;"> <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> <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>
<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">${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-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> <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], ['Nhập trong kỳ', asset?.ImportInPeriod ?? 0],
['Xuất trong kỳ', asset?.ExportInPeriod ?? 0], ['Xuất trong kỳ', asset?.ExportInPeriod ?? 0],
['Tồn cuối kỳ', asset?.EndingBalance ?? 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], ['Phòng ban', asset?.Department],
['Dự án', asset?.Project], ['Dự án', asset?.Project],
['Vị trí', asset?.Location], ['Vị trí', asset?.Location],
@@ -3922,16 +3994,36 @@ class AccountManager {
populateAssetForm(asset) { populateAssetForm(asset) {
const sourceAsset = asset || {}; const sourceAsset = asset || {};
const borrowerEntries = this.parseBorrowerEntries(sourceAsset?.Borrower); 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.editingAssetBorrowerEntries = borrowerEntries;
this.editingAssetStockSnapshot = {
endingBalance: metrics.endingBalance,
newQuantity: stockSplit.newQuantity,
usedQuantity: stockSplit.usedQuantity
};
this.clearAssetFormValidation(); this.clearAssetFormValidation();
document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || ''; document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || '';
document.getElementById('assetNameInput').value = sourceAsset?.AssetName || ''; 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('assetModelInput').value = sourceAsset?.Model || '';
document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || ''; document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || '';
document.getElementById('assetQuantityInput').value = this.parseNonNegativeInteger(sourceAsset?.Quantity, 0); document.getElementById('assetQuantityInput').value = metrics.quantity;
document.getElementById('assetImportInPeriodInput').value = this.parseNonNegativeInteger(sourceAsset?.ImportInPeriod, 0); document.getElementById('assetImportInPeriodInput').value = metrics.importInPeriod;
document.getElementById('assetUnitInput').value = sourceAsset?.Unit || ''; document.getElementById('assetUnitInput').value = sourceAsset?.Unit || '';
this.refreshAssetDepartmentOptions(sourceAsset?.Department || ''); this.refreshAssetDepartmentOptions(sourceAsset?.Department || '');
this.refreshAssetProjectOptions(sourceAsset?.Project || ''); this.refreshAssetProjectOptions(sourceAsset?.Project || '');
@@ -3962,6 +4054,7 @@ class AccountManager {
} }
if (this.editingAssetId === undefined) { if (this.editingAssetId === undefined) {
this.editingAssetStockSnapshot = null;
this.populateAssetForm(null); this.populateAssetForm(null);
} }
@@ -4087,19 +4180,36 @@ class AccountManager {
const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null;
const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
const endingBalance = endingBalanceInput !== null ? endingBalanceInput : computedEndingBalance; 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(); const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim();
return { return {
assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '', assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '',
assetName: document.getElementById('assetNameInput')?.value?.trim() || '', assetName: document.getElementById('assetNameInput')?.value?.trim() || '',
status: document.getElementById('assetStatusInput')?.value || 'in_use', status,
model: document.getElementById('assetModelInput')?.value?.trim() || '', model: document.getElementById('assetModelInput')?.value?.trim() || '',
serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '', serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '',
quantity, quantity,
importInPeriod, importInPeriod,
exportInPeriod, exportInPeriod,
endingBalance, endingBalance,
newQuantity: nextNewQuantity,
usedQuantity: nextUsedQuantity,
unit: document.getElementById('assetUnitInput')?.value?.trim() || '', unit: document.getElementById('assetUnitInput')?.value?.trim() || '',
department: document.getElementById('assetDepartmentInput')?.value?.trim() || '', department: document.getElementById('assetDepartmentInput')?.value?.trim() || '',
project: document.getElementById('assetProjectInput')?.value?.trim() || '', project: document.getElementById('assetProjectInput')?.value?.trim() || '',
@@ -4124,6 +4234,13 @@ class AccountManager {
const resolvedBaseEndingBalance = baseEndingBalance !== null const resolvedBaseEndingBalance = baseEndingBalance !== null
? baseEndingBalance ? baseEndingBalance
: Math.max(quantity + importInPeriod - baseExportInPeriod, 0); : 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) const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? borrowerEntriesOverride ? borrowerEntriesOverride
: this.parseBorrowerEntries(asset?.Borrower); : this.parseBorrowerEntries(asset?.Borrower);
@@ -4131,6 +4248,8 @@ class AccountManager {
let exportInPeriod = baseExportInPeriod; let exportInPeriod = baseExportInPeriod;
let endingBalance = resolvedBaseEndingBalance; let endingBalance = resolvedBaseEndingBalance;
let newQuantity = resolvedStockSplit.newQuantity;
let usedQuantity = resolvedStockSplit.usedQuantity;
if (Array.isArray(borrowerEntriesOverride)) { if (Array.isArray(borrowerEntriesOverride)) {
const existingBorrowerEntries = this.parseBorrowerEntries(asset?.Borrower); const existingBorrowerEntries = this.parseBorrowerEntries(asset?.Borrower);
const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => ( const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => (
@@ -4142,6 +4261,13 @@ class AccountManager {
const exportDelta = nextBorrowerExport - previousBorrowerExport; const exportDelta = nextBorrowerExport - previousBorrowerExport;
exportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0); exportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0);
endingBalance = Math.max(resolvedBaseEndingBalance - 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; const rawPrice = asset?.PurchasePrice;
@@ -4152,13 +4278,15 @@ class AccountManager {
return { return {
assetCode: String(asset?.AssetCode || '').trim(), assetCode: String(asset?.AssetCode || '').trim(),
assetName: String(asset?.AssetName || '').trim(), assetName: String(asset?.AssetName || '').trim(),
status: String(asset?.Status || 'in_use'), status: this.computeAssetStatusCode(endingBalance, exportInPeriod),
model: String(asset?.Model || '').trim(), model: String(asset?.Model || '').trim(),
serialNumber: String(asset?.SerialNumber || '').trim(), serialNumber: String(asset?.SerialNumber || '').trim(),
quantity, quantity,
importInPeriod, importInPeriod,
exportInPeriod, exportInPeriod,
endingBalance, endingBalance,
newQuantity,
usedQuantity,
unit: String(asset?.Unit || '').trim(), unit: String(asset?.Unit || '').trim(),
department: String(asset?.Department || '').trim(), department: String(asset?.Department || '').trim(),
project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(), project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(),
@@ -4398,6 +4526,7 @@ class AccountManager {
} }
this.editingAssetId = undefined; 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.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(); this.closeModals();
await this.refreshAssetsUI(); await this.refreshAssetsUI();

View File

@@ -222,12 +222,7 @@
</div> </div>
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Trạng thái</label> <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"> <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">
<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>
</div> </div>
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Model</label> <label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Model</label>