import fix

This commit is contained in:
2026-05-06 11:14:10 +07:00
parent 197186eac8
commit 8b2a9d7afe
4 changed files with 264 additions and 57 deletions

View File

@@ -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();

View File

@@ -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, '&amp;')
@@ -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(),

BIN
tmp-import-source.xls Normal file

Binary file not shown.

BIN
tmp-user-import.xls Normal file

Binary file not shown.