370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import * as XLSX from 'xlsx';
|
|
import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import';
|
|
|
|
function detectHeaderRow(columns: unknown[][]): number {
|
|
if (!columns || columns.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const firstRowWithValue = columns.map((column) =>
|
|
column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1)
|
|
);
|
|
|
|
const percentages = [0.95, 0.75];
|
|
|
|
for (const minPercentage of percentages) {
|
|
const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b);
|
|
|
|
for (const rowNumber of uniqueRows) {
|
|
if (rowNumber === -1) continue;
|
|
|
|
const columnsWithValues = columns.filter((column) => {
|
|
return column[rowNumber] !== undefined &&
|
|
column[rowNumber] !== null &&
|
|
String(column[rowNumber]).length > 1;
|
|
}).length;
|
|
|
|
if (columnsWithValues / columns.length >= minPercentage) {
|
|
return rowNumber;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
try {
|
|
const data = e.target?.result;
|
|
if (!data) {
|
|
reject(new Error('Failed to read file'));
|
|
return;
|
|
}
|
|
|
|
const workbook = XLSX.read(data, { type: 'binary' });
|
|
const firstSheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[firstSheetName];
|
|
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][];
|
|
|
|
if (jsonData.length === 0) {
|
|
reject(new Error('File is empty'));
|
|
return;
|
|
}
|
|
|
|
const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0));
|
|
const columns: unknown[][] = [];
|
|
|
|
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
|
|
const columnData = jsonData.map(row =>
|
|
Array.isArray(row) ? row[colIndex] : undefined
|
|
);
|
|
columns.push(columnData);
|
|
}
|
|
|
|
const headerRowIndex = detectHeaderRow(columns);
|
|
|
|
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
|
|
|
|
const headers = columns.map((column, index) => {
|
|
const headerValue = column[headerRowIndex];
|
|
const headerStr = String(headerValue || '').trim();
|
|
|
|
if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) {
|
|
return headerStr;
|
|
}
|
|
|
|
return letters[index] || `Column ${index + 1}`;
|
|
});
|
|
|
|
const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][];
|
|
|
|
const parsedData: ParsedRow[] = dataRows
|
|
.filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== ''))
|
|
.map(row => {
|
|
const obj: ParsedRow = {};
|
|
headers.forEach((header, index) => {
|
|
if (header) {
|
|
const value = row[index];
|
|
obj[header] = (value === null || value === undefined) ? null : value as string | number;
|
|
}
|
|
});
|
|
return obj;
|
|
});
|
|
|
|
resolve({ headers, data: parsedData, columns, headerRowIndex });
|
|
} catch {
|
|
reject(new Error('Failed to parse file'));
|
|
}
|
|
};
|
|
|
|
reader.onerror = () => {
|
|
reject(new Error('Failed to read file'));
|
|
};
|
|
|
|
reader.readAsBinaryString(file);
|
|
});
|
|
}
|
|
|
|
export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null {
|
|
if (!data || data.length === 0 || !dateColumnName) {
|
|
return null;
|
|
}
|
|
|
|
const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear];
|
|
const sampleSize = Math.min(10, data.length);
|
|
const scores: Record<DateFormat, number> = {
|
|
[DateFormat.YearMonthDay]: 0,
|
|
[DateFormat.DayMonthYear]: 0,
|
|
[DateFormat.MonthDayYear]: 0,
|
|
};
|
|
|
|
for (let i = 0; i < sampleSize; i++) {
|
|
const dateValue = data[i][dateColumnName];
|
|
if (!dateValue) continue;
|
|
|
|
for (const format of formats) {
|
|
const parsedDate = parseDate(dateValue as string | number, format);
|
|
if (parsedDate) {
|
|
scores[format]++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const maxScore = Math.max(...Object.values(scores));
|
|
|
|
if (maxScore === 0) {
|
|
return null;
|
|
}
|
|
|
|
const bestFormat = formats.find(format => scores[format] === maxScore);
|
|
|
|
if (maxScore >= sampleSize * 0.8) {
|
|
return bestFormat || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function autoDetectColumns(headers: string[]): ColumnMapping {
|
|
const mapping: ColumnMapping = {
|
|
transaction_date: null,
|
|
description: null,
|
|
amount: null,
|
|
};
|
|
|
|
if (!headers || headers.length === 0) {
|
|
return mapping;
|
|
}
|
|
|
|
const lowerHeaders = headers.map(h => {
|
|
if (h === null || h === undefined) {
|
|
return '';
|
|
}
|
|
return String(h).toLowerCase();
|
|
});
|
|
|
|
const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor'];
|
|
const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción'];
|
|
const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad'];
|
|
|
|
for (let i = 0; i < lowerHeaders.length; i++) {
|
|
const header = lowerHeaders[i];
|
|
const originalHeader = headers[i];
|
|
|
|
if (!header || typeof header !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) {
|
|
mapping.transaction_date = originalHeader;
|
|
}
|
|
|
|
if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) {
|
|
mapping.description = originalHeader;
|
|
}
|
|
|
|
if (!mapping.amount && amountPatterns.some(p => header.includes(p))) {
|
|
mapping.amount = originalHeader;
|
|
}
|
|
}
|
|
|
|
return mapping;
|
|
}
|
|
|
|
export function parseDate(dateStr: string | number, format: DateFormat): Date | null {
|
|
if (!dateStr) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof dateStr === 'number') {
|
|
const excelDate = XLSX.SSF.parse_date_code(dateStr);
|
|
if (excelDate) {
|
|
return new Date(excelDate.y, excelDate.m - 1, excelDate.d);
|
|
}
|
|
}
|
|
|
|
let str = String(dateStr).trim();
|
|
str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, '');
|
|
|
|
let year: number | undefined, month: number | undefined, day: number | undefined;
|
|
|
|
if (str.length === 5) {
|
|
const dateRegex = /^(\d{1,2})-(\d{1,2})$/;
|
|
const dateArray = dateRegex.exec(str);
|
|
if (dateArray) {
|
|
month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]);
|
|
day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]);
|
|
}
|
|
} else {
|
|
const parts = str.split('-').filter(p => p.length > 0);
|
|
|
|
if (parts.length === 3) {
|
|
switch (format) {
|
|
case DateFormat.YearMonthDay:
|
|
[year, month, day] = parts.map(Number);
|
|
break;
|
|
case DateFormat.MonthDayYear:
|
|
[month, day, year] = parts.map(Number);
|
|
break;
|
|
case DateFormat.DayMonthYear:
|
|
[day, month, year] = parts.map(Number);
|
|
break;
|
|
}
|
|
} else if (parts.length === 2) {
|
|
month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]);
|
|
day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]);
|
|
}
|
|
}
|
|
|
|
if (year === undefined) {
|
|
year = new Date().getFullYear();
|
|
}
|
|
|
|
if (year < 100) {
|
|
year += year < 50 ? 2000 : 1900;
|
|
}
|
|
|
|
if (year === undefined || month === undefined || day === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const date = new Date(year, month - 1, day);
|
|
|
|
if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
return null;
|
|
}
|
|
|
|
return date;
|
|
}
|
|
|
|
export function parseAmount(amountStr: string | number): number | null {
|
|
if (typeof amountStr === 'number') {
|
|
return amountStr;
|
|
}
|
|
|
|
if (!amountStr) {
|
|
return null;
|
|
}
|
|
|
|
let str = String(amountStr).trim();
|
|
|
|
const dotPos = str.lastIndexOf('.');
|
|
const commaPos = str.lastIndexOf(',');
|
|
|
|
const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos :
|
|
commaPos > dotPos && commaPos !== -1 ? commaPos :
|
|
-1;
|
|
|
|
if (decimalSep !== -1) {
|
|
const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, '');
|
|
const decimalPart = str.substring(decimalSep + 1);
|
|
str = integerPart + '.' + decimalPart;
|
|
} else {
|
|
str = str.replace(/[^\d]/g, '');
|
|
}
|
|
|
|
const amount = parseFloat(str);
|
|
|
|
if (isNaN(amount)) {
|
|
return null;
|
|
}
|
|
|
|
return amount;
|
|
}
|
|
|
|
export function validateTransaction(
|
|
row: ParsedRow,
|
|
mapping: ColumnMapping,
|
|
dateFormat: DateFormat
|
|
): { isValid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
|
|
if (!mapping.transaction_date || !row[mapping.transaction_date]) {
|
|
errors.push('Missing transaction date');
|
|
} else {
|
|
const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat);
|
|
if (!date) {
|
|
errors.push('Invalid date format');
|
|
}
|
|
}
|
|
|
|
if (!mapping.description || !row[mapping.description]) {
|
|
errors.push('Missing description');
|
|
}
|
|
|
|
if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) {
|
|
errors.push('Missing amount');
|
|
} else {
|
|
const amount = parseAmount(row[mapping.amount] as string | number);
|
|
if (amount === null) {
|
|
errors.push('Invalid amount format');
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
export function convertRowsToTransactions(
|
|
rows: ParsedRow[],
|
|
mapping: ColumnMapping,
|
|
dateFormat: DateFormat
|
|
): ParsedTransaction[] {
|
|
const results: ParsedTransaction[] = [];
|
|
|
|
for (const row of rows) {
|
|
const validation = validateTransaction(row, mapping, dateFormat);
|
|
|
|
if (!validation.isValid) {
|
|
continue;
|
|
}
|
|
|
|
const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat);
|
|
const amount = parseAmount(row[mapping.amount!] as string | number);
|
|
const description = String(row[mapping.description!] || '').trim();
|
|
|
|
if (!date || amount === null || !description) {
|
|
continue;
|
|
}
|
|
|
|
const formattedDate = date.toISOString().split('T')[0];
|
|
|
|
results.push({
|
|
transaction_date: formattedDate,
|
|
description,
|
|
amount,
|
|
validationErrors: [],
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|