diff --git a/backend/server.js b/backend/server.js index 2d5e255..b766f89 100644 --- a/backend/server.js +++ b/backend/server.js @@ -329,8 +329,29 @@ function parsePositiveInteger(value, fallback = 1) { } function parseNonNegativeInteger(value, fallback = 0) { - const parsed = Number(value); - if (Number.isInteger(parsed) && parsed >= 0) { + const parsed = parseAssetImportNumericValue(value, Number.NaN); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.floor(parsed); + } + return fallback; +} + +function parseOptionalNonNegativeInteger(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + + const parsed = parseAssetImportNumericValue(value, Number.NaN); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.floor(parsed); + } + + return null; +} + +function parseNonNegativeIntegerOrFallback(value, fallback = 0) { + const parsed = parseOptionalNonNegativeInteger(value); + if (parsed !== null) { return parsed; } return fallback; @@ -406,8 +427,11 @@ function normalizeAssetStatus(value) { } function normalizeAssetPayload(payload = {}) { - const quantity = parseNonNegativeInteger(payload.quantity, 0); - const endingBalance = parseNonNegativeInteger(payload.endingBalance, quantity); + 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; return { assetCode: String(payload.assetCode || '').trim(), @@ -418,8 +442,8 @@ function normalizeAssetPayload(payload = {}) { unit: String(payload.unit || '').trim() || null, department: String(payload.department || '').trim() || null, project: String(payload.project || '').trim() || null, - importInPeriod: parseNonNegativeInteger(payload.importInPeriod, 0), - exportInPeriod: parseNonNegativeInteger(payload.exportInPeriod, 0), + importInPeriod, + exportInPeriod, endingBalance, location: String(payload.location || '').trim() || null, custodian: String(payload.custodian || '').trim() || null, @@ -648,6 +672,28 @@ function isHeaderLikeAssetImportRow(row = {}) { return headerLikeCount >= 2; } +function isMeaningfulImportedAssetRow(row = {}) { + const assetName = String(row.assetName || '').trim(); + if (!assetName) { + return false; + } + + return [ + row.assetCode, + row.assetName, + row.model, + row.unit, + row.location, + row.department, + row.project, + row.notes, + row.quantity, + row.importInPeriod, + row.exportInPeriod, + row.endingBalance + ].some(value => String(value ?? '').trim() !== ''); +} + const ASSET_IMPORT_ALIASES = { stt: ['STT', 'So thu tu'], assetCode: ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'], @@ -815,6 +861,7 @@ function parseAssetImportRowsByHeaderMap(matrixRows) { ); const mapped = { + sourceStt: parseAssetImportSttNumber(pick(row, bestFieldMap.stt)), assetCode: String(pick(row, bestFieldMap.assetCode)).trim(), assetName: String(pick(row, bestFieldMap.assetName)).trim(), model: String(pick(row, bestFieldMap.model)).trim(), @@ -844,7 +891,7 @@ function parseAssetImportRowsByHeaderMap(matrixRows) { }) .filter(Boolean) .filter(row => !isHeaderLikeAssetImportRow(row)) - .filter(row => row.assetCode && row.assetName); + .filter(row => isMeaningfulImportedAssetRow(row)); return parsed; } @@ -880,12 +927,46 @@ function inferAssetImportColumnIndex(headerRow, aliases = []) { return -1; } -function parseAssetImportNumericValue(value, fallback = 1) { +function parseAssetImportNumericValue(value, fallback = 0) { if (value === undefined || value === null || value === '') { return fallback; } - const normalized = String(value).trim().replace(/,/g, ''); + const raw = String(value).trim(); + if (!raw) { + return fallback; + } + + const compact = raw.replace(/\s+/g, ''); + let normalized = compact; + + const hasDot = compact.includes('.'); + const hasComma = compact.includes(','); + + // vi-VN style: 1.234,56 or 1.234 + if (hasDot && hasComma) { + if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(compact)) { + normalized = compact.replace(/\./g, '').replace(',', '.'); + } else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(compact)) { + // en-US style: 1,234.56 + normalized = compact.replace(/,/g, ''); + } else { + normalized = compact.replace(/,/g, ''); + } + } else if (hasDot) { + if (/^-?\d{1,3}(\.\d{3})+$/.test(compact)) { + normalized = compact.replace(/\./g, ''); + } + } else if (hasComma) { + if (/^-?\d{1,3}(,\d{3})+$/.test(compact)) { + normalized = compact.replace(/,/g, ''); + } else if (/^-?\d+,\d+$/.test(compact)) { + normalized = compact.replace(',', '.'); + } else { + normalized = compact.replace(/,/g, ''); + } + } + if (!normalized) { return fallback; } @@ -929,7 +1010,9 @@ function generateImportAssetCodeFromRow(mapped, rowNumber = 0) { const fromSerial = sanitizeAssetCodeToken(mapped.serialNumber); const fromName = sanitizeAssetCodeToken(mapped.assetName); const base = fromModel || fromSerial || fromName || 'ASSET'; - const suffix = String(rowNumber || 0).padStart(4, '0'); + const sttNumber = parseAssetImportSttNumber(mapped?.sourceStt); + const suffixSeed = sttNumber || rowNumber || 0; + const suffix = String(suffixSeed).padStart(4, '0'); return `IMP-${base}-${suffix}`; } @@ -1058,6 +1141,7 @@ function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) { ); const mapped = { + sourceStt: sttValue, assetCode: String(pick(row, indexMap.assetCode)).trim(), assetName: String(pick(row, indexMap.assetName)).trim(), model: String(pick(row, indexMap.model)).trim(), @@ -1081,7 +1165,7 @@ function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) { }) .filter(Boolean) .filter(row => !isHeaderLikeAssetImportRow(row)) - .filter(row => row.assetCode && row.assetName); + .filter(row => isMeaningfulImportedAssetRow(row)); } function parseAssetImportRowsFromMatrix(matrixRows) { @@ -1149,8 +1233,10 @@ function parseAssetImportRowsFromMatrix(matrixRows) { return sttRows .map((row, idx) => { + const sttValue = parseAssetImportSttNumber(row[detectedSttCol]); const endingBalance = parseAssetImportNumericValue(row[detectedSttCol + 9] ?? '', 0); const mapped = { + sourceStt: sttValue, assetCode: String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(), assetName: String(row[detectedSttCol + 3] ?? '').trim() || String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(), model: String(row[detectedSttCol + 4] ?? '').trim(), @@ -1173,7 +1259,7 @@ function parseAssetImportRowsFromMatrix(matrixRows) { return finalizeImportedAssetPayload(mapped, idx + 2); }) .filter(row => !isHeaderLikeAssetImportRow(row)) - .filter(row => row.assetCode && row.assetName); + .filter(row => isMeaningfulImportedAssetRow(row)); } function detectLikelySttColumn(matrixRows) { @@ -1248,8 +1334,10 @@ function parseAssetImportRowsLoose(matrixRows) { return dataRows .map((row, idx) => { + const sttValue = parseAssetImportSttNumber(row[sttCol]); const endingBalance = parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0); const mapped = { + sourceStt: sttValue, assetCode: String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(), assetName: String(row[sttCol + 3] ?? '').trim() || String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(), model: String(row[sttCol + 4] ?? '').trim(), @@ -1272,7 +1360,7 @@ function parseAssetImportRowsLoose(matrixRows) { return finalizeImportedAssetPayload(mapped, idx + 2); }) .filter(row => !isHeaderLikeAssetImportRow(row)) - .filter(row => row.assetCode && row.assetName); + .filter(row => isMeaningfulImportedAssetRow(row)); } function parseAssetImportRows(matrixRows) { @@ -1295,11 +1383,53 @@ function countNonEmptyMatrixRows(matrixRows) { ).length; } +async function ensureUniqueImportAssetCode(transaction, initialCode, maxAttempts = 12) { + let candidate = String(initialCode || '').trim(); + if (!candidate) { + candidate = generateManualAssetCodeFromPayload({}); + } + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const existed = await new sql.Request(transaction) + .input('assetCode', sql.NVarChar, candidate) + .query(` + SELECT TOP 1 AssetId + FROM AssetInventory + WHERE AssetCode = @assetCode + `); + + if (existed.recordset.length === 0) { + return candidate; + } + + const suffix = String(attempt + 1).padStart(2, '0'); + candidate = `${String(initialCode || 'IMP-ASSET').slice(0, 90)}-${suffix}`; + } + + return generateManualAssetCodeFromPayload({ assetName: initialCode || 'IMPORT' }); +} + +function scoreAssetImportSheetName(sheetName = '') { + const token = normalizeImportToken(sheetName); + if (!token) { + return 0; + } + + let score = 0; + if (token.includes('xuatnhapton')) score += 120; + if (token.includes('kho')) score += 60; + if (token.includes('robotics')) score += 40; + if (token.includes('inventory')) score += 40; + if (token.includes('asset')) score += 30; + return score; +} + function parseAssetImportRowsFromWorkbook(workbook) { const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : []; let bestRows = []; let bestSheetName = ''; let bestNonEmptyRows = 0; + let bestSheetPriority = 0; const diagnostics = []; for (const sheetName of sheetNames) { @@ -1316,16 +1446,23 @@ function parseAssetImportRowsFromWorkbook(workbook) { const parsedRows = parseAssetImportRows(matrixRows); const nonEmptyRows = countNonEmptyMatrixRows(matrixRows); + const sheetPriority = scoreAssetImportSheetName(sheetName); diagnostics.push({ sheetName, parsedRows: parsedRows.length, - nonEmptyRows + nonEmptyRows, + sheetPriority }); - if (parsedRows.length > bestRows.length || (parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)) { + if ( + (sheetPriority > bestSheetPriority && parsedRows.length > 0) + || (sheetPriority === bestSheetPriority && parsedRows.length > bestRows.length) + || (sheetPriority === bestSheetPriority && parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows) + ) { bestRows = parsedRows; bestSheetName = sheetName; bestNonEmptyRows = nonEmptyRows; + bestSheetPriority = sheetPriority; } } @@ -4006,16 +4143,18 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async const normalizedRows = incomingRows .map((row, rowIndex) => { const normalized = normalizeAssetPayload(row); - if (!normalized.assetCode && normalized.assetName) { + const hasOriginalAssetCode = String(normalized.assetCode || '').trim() !== ''; + if (!hasOriginalAssetCode && normalized.assetName) { normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1); } + normalized.__hasOriginalAssetCode = hasOriginalAssetCode; return normalized; }) .filter(row => !isHeaderLikeAssetImportRow(row)) - .filter(row => row.assetCode && row.assetName); + .filter(row => isMeaningfulImportedAssetRow(row)); if (!normalizedRows.length) { - return res.status(400).json({ success: false, message: 'No valid rows found. Asset code and name are required.' }); + return res.status(400).json({ success: false, message: 'No valid rows found. Asset name or model is required.' }); } const transaction = new sql.Transaction(pool); @@ -4026,6 +4165,48 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async await transaction.begin(); for (const row of normalizedRows) { + if (!row.__hasOriginalAssetCode) { + const uniqueImportCode = await ensureUniqueImportAssetCode(transaction, row.assetCode); + row.assetCode = uniqueImportCode; + await new sql.Request(transaction) + .input('assetCode', sql.NVarChar, row.assetCode) + .input('assetName', sql.NVarChar, row.assetName) + .input('model', sql.NVarChar, row.model) + .input('serialNumber', sql.NVarChar, row.serialNumber) + .input('quantity', sql.Int, row.quantity) + .input('importInPeriod', sql.Int, row.importInPeriod) + .input('exportInPeriod', sql.Int, row.exportInPeriod) + .input('endingBalance', sql.Int, row.endingBalance) + .input('unit', sql.NVarChar, row.unit) + .input('department', sql.NVarChar, row.department) + .input('project', sql.NVarChar, row.project) + .input('location', sql.NVarChar, row.location) + .input('custodian', sql.NVarChar, row.custodian) + .input('borrower', sql.NVarChar, row.borrower) + .input('exportedBy', sql.NVarChar, exportedBy) + .input('purchaseDate', sql.Date, row.purchaseDate) + .input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice) + .input('status', sql.NVarChar, row.status) + .input('notes', sql.NVarChar, row.notes) + .input('createdBy', sql.Int, createdBy) + .query(` + INSERT INTO AssetInventory ( + AssetCode, AssetName, Model, SerialNumber, + Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, + Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, + PurchaseDate, PurchasePrice, Status, Notes, CreatedBy + ) + VALUES ( + @assetCode, @assetName, @model, @serialNumber, + @quantity, @importInPeriod, @exportInPeriod, @endingBalance, + @unit, @department, @project, @location, @custodian, @borrower, @exportedBy, + @purchaseDate, @purchasePrice, @status, @notes, @createdBy + ); + `); + inserted += 1; + continue; + } + const mergeResult = await new sql.Request(transaction) .input('assetCode', sql.NVarChar, row.assetCode) .input('assetName', sql.NVarChar, row.assetName) @@ -4089,11 +4270,8 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async `); const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase(); - if (mergeAction === 'INSERT') { - inserted += 1; - } else if (mergeAction === 'UPDATE') { - updated += 1; - } + if (mergeAction === 'INSERT') inserted += 1; + if (mergeAction === 'UPDATE') updated += 1; } await transaction.commit(); diff --git a/public/js/app.js b/public/js/app.js index 5b3d780..0ca036f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1637,6 +1637,19 @@ class AccountManager { return parsed; } + parseOptionalNonNegativeInteger(value) { + if (value === null || value === undefined || String(value).trim() === '') { + return null; + } + + const parsed = Number.parseInt(String(value).replace(/,/g, '').trim(), 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + + return parsed; + } + escapeHtml(value) { return String(value || '') .replace(/&/g, '&') @@ -1850,17 +1863,26 @@ class AccountManager { const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? this.parseBorrowerEntries(borrowerEntriesOverride) : this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower); - const exportInPeriod = borrowerEntries.reduce((sum, entry) => ( + const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); - const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); + const storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod); + const exportInPeriod = storedExportInPeriod !== null + ? storedExportInPeriod + : borrowerExportInPeriod; + const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); + const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance); + const endingBalance = storedEndingBalance !== null + ? storedEndingBalance + : computedEndingBalance; return { quantity, importInPeriod, exportInPeriod, endingBalance, - borrowerEntries + borrowerEntries, + borrowerExportInPeriod }; } @@ -1890,19 +1912,16 @@ class AccountManager { return; } - const metrics = this.buildAssetQuantityMetrics( - { - Quantity: quantityInput.value, - ImportInPeriod: importInput.value - }, - this.editingAssetBorrowerEntries - ); + const quantity = this.parseNonNegativeInteger(quantityInput.value, 0); + const importInPeriod = this.parseNonNegativeInteger(importInput.value, 0); + const exportInPeriod = this.parseNonNegativeInteger(exportInput?.value ?? 0, 0); + const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); if (exportInput) { - exportInput.value = String(metrics.exportInPeriod); + exportInput.value = String(exportInPeriod); } if (endingInput) { - endingInput.value = String(metrics.endingBalance); + endingInput.value = String(endingBalance); } } @@ -3944,18 +3963,14 @@ class AccountManager { collectAssetFormPayload() { const quantity = this.parseNonNegativeInteger(document.getElementById('assetQuantityInput')?.value ?? 0, 0); const importInPeriod = this.parseNonNegativeInteger(document.getElementById('assetImportInPeriodInput')?.value ?? 0, 0); + const exportInPeriod = this.parseNonNegativeInteger(document.getElementById('assetExportInPeriodInput')?.value ?? 0, 0); + const endingBalanceInput = this.parseOptionalNonNegativeInteger(document.getElementById('assetEndingBalanceInput')?.value ?? ''); const borrowerEntries = Array.isArray(this.editingAssetBorrowerEntries) ? this.editingAssetBorrowerEntries : []; const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; - const metrics = this.buildAssetQuantityMetrics( - { - Quantity: quantity, - ImportInPeriod: importInPeriod, - Borrower: borrower - }, - borrowerEntries - ); + const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); + const endingBalance = endingBalanceInput !== null ? endingBalanceInput : computedEndingBalance; const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim(); @@ -3967,8 +3982,8 @@ class AccountManager { serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '', quantity, importInPeriod, - exportInPeriod: metrics.exportInPeriod, - endingBalance: metrics.endingBalance, + exportInPeriod, + endingBalance, unit: document.getElementById('assetUnitInput')?.value?.trim() || '', department: document.getElementById('assetDepartmentInput')?.value?.trim() || '', project: document.getElementById('assetProjectInput')?.value?.trim() || '', @@ -3986,18 +4001,32 @@ class AccountManager { return null; } + const quantity = this.parseNonNegativeInteger(asset?.Quantity, 0); + const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod, 0); + const baseExportInPeriod = this.parseNonNegativeInteger(asset?.ExportInPeriod, 0); + const baseEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance); + const resolvedBaseEndingBalance = baseEndingBalance !== null + ? baseEndingBalance + : Math.max(quantity + importInPeriod - baseExportInPeriod, 0); const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? borrowerEntriesOverride : this.parseBorrowerEntries(asset?.Borrower); const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; - const metrics = this.buildAssetQuantityMetrics( - { - Quantity: asset?.Quantity, - ImportInPeriod: asset?.ImportInPeriod, - Borrower: borrower - }, - borrowerEntries - ); + + let exportInPeriod = baseExportInPeriod; + let endingBalance = resolvedBaseEndingBalance; + if (Array.isArray(borrowerEntriesOverride)) { + const existingBorrowerEntries = this.parseBorrowerEntries(asset?.Borrower); + const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => ( + sum + this.parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const nextBorrowerExport = this.parseBorrowerEntries(borrowerEntriesOverride).reduce((sum, entry) => ( + sum + this.parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const exportDelta = nextBorrowerExport - previousBorrowerExport; + exportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0); + endingBalance = Math.max(resolvedBaseEndingBalance - exportDelta, 0); + } const rawPrice = asset?.PurchasePrice; const normalizedPrice = rawPrice === undefined || rawPrice === null || String(rawPrice).trim() === '' @@ -4010,10 +4039,10 @@ class AccountManager { status: String(asset?.Status || 'in_use'), model: String(asset?.Model || '').trim(), serialNumber: String(asset?.SerialNumber || '').trim(), - quantity: metrics.quantity, - importInPeriod: metrics.importInPeriod, - exportInPeriod: metrics.exportInPeriod, - endingBalance: metrics.endingBalance, + quantity, + importInPeriod, + exportInPeriod, + endingBalance, unit: String(asset?.Unit || '').trim(), department: String(asset?.Department || '').trim(), project: String(asset?.Project || '').trim(), diff --git a/tmp-import-source.xls b/tmp-import-source.xls new file mode 100644 index 0000000..edad7d2 Binary files /dev/null and b/tmp-import-source.xls differ diff --git a/tmp-user-import.xls b/tmp-user-import.xls new file mode 100644 index 0000000..d041cea Binary files /dev/null and b/tmp-user-import.xls differ