- Renamed project from PSA-Utils to Pulse - Moved all app files from autotask-app/ to root - Updated package.json name to 'pulse' - Updated Docker container names to pulse-app and pulse-redis - Updated Docker network name to pulse-network
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
|
|
|
// Extract serial number from lineItemFullDescription
|
|
function extractSerialNumber(description: string | null): string | null {
|
|
if (!description) return null;
|
|
|
|
// Look for "Serial Number(s)" followed by the serial
|
|
const match = description.match(/Serial Number\(s\)\s+([A-Z0-9]+)/i);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const configItemId = searchParams.get('configItemId');
|
|
const invoiceId = searchParams.get('invoiceId');
|
|
|
|
// If invoice ID is provided, just return billing items for that invoice
|
|
if (invoiceId) {
|
|
const autotaskClient = getAutotaskClient();
|
|
|
|
const billingItems = await autotaskClient.queryEntity('BillingItems', {
|
|
filter: [{ op: 'eq', field: 'invoiceID', value: parseInt(invoiceId) }],
|
|
});
|
|
|
|
console.log(`Found ${billingItems.length} billing items for invoice ${invoiceId}`);
|
|
|
|
return NextResponse.json({
|
|
configItem: null,
|
|
billingItems,
|
|
invoices: [],
|
|
tickets: [],
|
|
summary: {
|
|
totalBilled: billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0),
|
|
purchaseDate: null,
|
|
lastInvoiceDate: null,
|
|
ticketCount: 0,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!configItemId) {
|
|
return NextResponse.json(
|
|
{ error: 'Configuration Item ID or Invoice ID is required' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const autotaskClient = getAutotaskClient();
|
|
|
|
// Get the configuration item
|
|
const configItem = await autotaskClient.getConfigurationItemById(parseInt(configItemId));
|
|
|
|
if (!configItem) {
|
|
return NextResponse.json(
|
|
{ error: 'Configuration item not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Get date range from query params
|
|
const startDateParam = searchParams.get('startDate');
|
|
const endDateParam = searchParams.get('endDate');
|
|
const daysBackParam = searchParams.get('daysBack');
|
|
|
|
let startDateFilter: string;
|
|
let endDateFilter: string;
|
|
|
|
if (startDateParam && endDateParam) {
|
|
// Use explicit date range
|
|
startDateFilter = startDateParam;
|
|
endDateFilter = endDateParam;
|
|
console.log('Using explicit date range:', { startDateFilter, endDateFilter });
|
|
} else {
|
|
// Fallback to daysBack (for backward compatibility)
|
|
const daysBack = parseInt(daysBackParam || '90');
|
|
const dateAgo = new Date();
|
|
dateAgo.setDate(dateAgo.getDate() - daysBack);
|
|
startDateFilter = dateAgo.toISOString().split('T')[0];
|
|
endDateFilter = new Date().toISOString().split('T')[0];
|
|
console.log('Using daysBack:', daysBack);
|
|
}
|
|
|
|
console.log('Searching for enrichment data:', {
|
|
configItemId,
|
|
companyID: configItem.companyID,
|
|
serialNumber: configItem.serialNumber,
|
|
startDate: startDateFilter,
|
|
endDate: endDateFilter
|
|
});
|
|
|
|
let invoices: any[] = [];
|
|
try {
|
|
invoices = await autotaskClient.queryEntity('Invoices', {
|
|
filter: [
|
|
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
|
{ op: 'gte', field: 'invoiceDateTime', value: startDateFilter },
|
|
{ op: 'lte', field: 'invoiceDateTime', value: endDateFilter }
|
|
],
|
|
});
|
|
console.log(`Found ${invoices.length} invoices for company between ${startDateFilter} and ${endDateFilter}`);
|
|
} catch (err) {
|
|
console.error('Error fetching invoices:', err);
|
|
}
|
|
|
|
// Get billing items (line items) for all invoices - hardware only
|
|
let billingItems: any[] = [];
|
|
if (invoices.length > 0) {
|
|
try {
|
|
const invoiceIds = invoices.map(inv => inv.id);
|
|
// Fetch only hardware billing items (type 3) to reduce data volume
|
|
console.log(`Fetching hardware billing items (type 3) for company ${configItem.companyID}...`);
|
|
const allBillingItems = await autotaskClient.queryEntity('BillingItems', {
|
|
filter: [
|
|
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
|
{ op: 'gte', field: 'itemDate', value: startDateFilter },
|
|
{ op: 'lte', field: 'itemDate', value: endDateFilter },
|
|
{ op: 'eq', field: 'billingItemType', value: 3 } // Hardware only
|
|
],
|
|
});
|
|
console.log(`Fetched ${allBillingItems.length} hardware billing items`);
|
|
if (allBillingItems.length >= 500) {
|
|
console.warn('⚠️ Hit API limit of 500 records - older purchases may not be included');
|
|
}
|
|
|
|
// Enrich items (already filtered to hardware at API level)
|
|
const enrichedItems = allBillingItems
|
|
.map((item: any) => {
|
|
const serialNumber = extractSerialNumber(item.lineItemFullDescription);
|
|
const profit = (item.totalAmount || 0) - (item.ourCost || 0);
|
|
const margin = item.totalAmount ? ((profit / item.totalAmount) * 100).toFixed(1) : '0';
|
|
|
|
return {
|
|
...item,
|
|
extractedSerialNumber: serialNumber,
|
|
profit,
|
|
profitMargin: margin,
|
|
};
|
|
});
|
|
|
|
// If we have a config item serial number, filter to matching items
|
|
if (configItem.serialNumber) {
|
|
console.log(`Looking for serial: ${configItem.serialNumber}`);
|
|
console.log(`Extracted serials from billing items:`, enrichedItems.map((i: any) => i.extractedSerialNumber).filter(Boolean));
|
|
|
|
// Match if serial appears in extracted field OR anywhere in the description
|
|
billingItems = enrichedItems.filter((item: any) => {
|
|
const serial = configItem.serialNumber!.toLowerCase();
|
|
const extractedMatch = item.extractedSerialNumber &&
|
|
item.extractedSerialNumber.toLowerCase() === serial;
|
|
const descriptionMatch = item.lineItemFullDescription &&
|
|
item.lineItemFullDescription.toLowerCase().includes(serial);
|
|
return extractedMatch || descriptionMatch;
|
|
});
|
|
console.log(`Found ${billingItems.length} billing items matching serial ${configItem.serialNumber}`);
|
|
|
|
// If no exact match, return empty array (only show exact serial matches)
|
|
if (billingItems.length === 0) {
|
|
console.log(`No exact serial match found for ${configItem.serialNumber}`);
|
|
}
|
|
} else {
|
|
billingItems = enrichedItems;
|
|
console.log(`Found ${billingItems.length} hardware billing items (no serial filter)`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching billing items:', err);
|
|
}
|
|
}
|
|
|
|
// Calculate summary
|
|
const totalBilled = invoices.reduce((sum: number, invoice: any) =>
|
|
sum + (invoice.invoiceTotal || 0), 0
|
|
);
|
|
|
|
const lastInvoiceDate = invoices.length > 0
|
|
? invoices.sort((a: any, b: any) =>
|
|
new Date(b.invoiceDateTime).getTime() - new Date(a.invoiceDateTime).getTime()
|
|
)[0]?.invoiceDateTime
|
|
: null;
|
|
|
|
return NextResponse.json({
|
|
configItem,
|
|
billingItems,
|
|
invoices,
|
|
tickets: [],
|
|
summary: {
|
|
totalBilled,
|
|
purchaseDate: null,
|
|
lastInvoiceDate,
|
|
ticketCount: 0,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in config enrichment:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch enrichment data' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|