130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
|
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
|
import { findDuplicates, createExpenseConfig } from '~/server/utils/duplicate-detection';
|
|
import type { Expense } from '~/utils/types';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
console.log('[EXPENSES] Find duplicate expenses request');
|
|
|
|
try {
|
|
// Require sales or admin access
|
|
await requireSalesOrAdmin(event);
|
|
|
|
const query = getQuery(event);
|
|
const dateRange = query.dateRange as string || '30'; // Default to last 30 days
|
|
|
|
// Calculate date range
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - parseInt(dateRange));
|
|
|
|
// Get expenses from NocoDB
|
|
const config = getNocoDbConfiguration();
|
|
const expenseTableId = "mxfcefkk4dqs6uq";
|
|
|
|
const response = await $fetch(`${config.url}/api/v2/tables/${expenseTableId}/records`, {
|
|
headers: {
|
|
'xc-token': config.token
|
|
},
|
|
params: {
|
|
limit: 5000,
|
|
where: `(Time,gte,${startDate.toISOString().split('T')[0]})~and(Time,lte,${endDate.toISOString().split('T')[0]})`,
|
|
sort: '-Time'
|
|
}
|
|
}) as any;
|
|
|
|
const expenses = response.list || [];
|
|
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
|
|
|
// Find duplicate groups using the new centralized utility
|
|
const duplicateConfig = createExpenseConfig();
|
|
const duplicateGroups = findDuplicates(expenses, duplicateConfig);
|
|
|
|
// Convert to the expected format
|
|
const formattedGroups = duplicateGroups.map(group => ({
|
|
id: group.id,
|
|
expenses: group.items,
|
|
matchReason: group.matchReason,
|
|
confidence: group.confidence,
|
|
masterCandidate: group.masterCandidate
|
|
}));
|
|
|
|
// Also find payer name variations
|
|
const payerVariations = findPayerNameVariations(expenses);
|
|
|
|
console.log('[EXPENSES] Found', formattedGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
duplicateGroups: formattedGroups,
|
|
payerVariations,
|
|
totalExpenses: expenses.length,
|
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
|
dateRange: {
|
|
start: startDate.toISOString().split('T')[0],
|
|
end: endDate.toISOString().split('T')[0]
|
|
}
|
|
}
|
|
};
|
|
|
|
} catch (error: any) {
|
|
console.error('[EXPENSES] Failed to find duplicates:', error);
|
|
|
|
if (error.statusCode === 403) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: 'Access denied. Sales or admin role required.'
|
|
});
|
|
}
|
|
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Failed to find duplicate expenses'
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* Find payer name variations (like "Abbie" vs "abbie")
|
|
*/
|
|
function findPayerNameVariations(expenses: any[]) {
|
|
const payerMap = new Map<string, Set<string>>();
|
|
|
|
// Group payers by normalized name
|
|
expenses.forEach(expense => {
|
|
if (expense.Payer) {
|
|
const normalized = normalizePersonName(expense.Payer);
|
|
if (!payerMap.has(normalized)) {
|
|
payerMap.set(normalized, new Set());
|
|
}
|
|
payerMap.get(normalized)!.add(expense.Payer);
|
|
}
|
|
});
|
|
|
|
// Find variations
|
|
const variations: Array<{
|
|
normalizedName: string;
|
|
variations: string[];
|
|
expenseCount: number;
|
|
}> = [];
|
|
|
|
payerMap.forEach((variationSet, normalized) => {
|
|
if (variationSet.size > 1) {
|
|
const variationArray = Array.from(variationSet);
|
|
const expenseCount = expenses.filter(e =>
|
|
e.Payer && normalizePersonName(e.Payer) === normalized
|
|
).length;
|
|
|
|
variations.push({
|
|
normalizedName: normalized,
|
|
variations: variationArray,
|
|
expenseCount
|
|
});
|
|
}
|
|
});
|
|
|
|
return variations.sort((a, b) => b.expenseCount - a.expenseCount);
|
|
}
|