bulk read flow added and will trigger after first time / re-auth authetication
This commit is contained in:
parent
ec68e5ca39
commit
7352fec243
@ -1091,6 +1091,24 @@ const scheduleBulkReadJobs = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCrmCounts(req, res) {
|
||||||
|
try {
|
||||||
|
const { provider } = req.query;
|
||||||
|
|
||||||
|
if (provider !== 'zoho') {
|
||||||
|
return res.status(400).json(failure('Only Zoho provider is supported for CRM counts', 'UNSUPPORTED_PROVIDER'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const zohoClient = new ZohoClient(req.user.uuid);
|
||||||
|
const counts = await zohoClient.getCrmCounts();
|
||||||
|
|
||||||
|
res.json(success('Zoho CRM module counts retrieved successfully', counts));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting CRM counts:', error);
|
||||||
|
res.status(500).json(failure(error.message, 'CRM_COUNTS_ERROR'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getData, getServices, getResources, getPortals, getAllProjects, getAllProjectTasks,
|
getData, getServices, getResources, getPortals, getAllProjects, getAllProjectTasks,
|
||||||
getAllProjectTaskLists, getAllProjectIssues, getAllProjectPhases, getSalesOrders,
|
getAllProjectTaskLists, getAllProjectIssues, getAllProjectPhases, getSalesOrders,
|
||||||
@ -1099,5 +1117,5 @@ module.exports = {
|
|||||||
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
||||||
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
||||||
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
||||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs
|
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs, getCrmCounts
|
||||||
};
|
};
|
||||||
|
|||||||
@ -96,6 +96,22 @@ class ReportsController {
|
|||||||
purchaseOrders: purchaseOrdersData
|
purchaseOrders: purchaseOrdersData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add advanced financial KPIs
|
||||||
|
const advancedKPIs = this.calculateAdvancedFinancialKPIs({
|
||||||
|
leads: leadsData,
|
||||||
|
tasks: tasksData,
|
||||||
|
contacts: contactsData,
|
||||||
|
accounts: accountsData,
|
||||||
|
deals: dealsData,
|
||||||
|
vendors: vendorsData,
|
||||||
|
invoices: invoicesData,
|
||||||
|
salesOrders: salesOrdersData,
|
||||||
|
purchaseOrders: purchaseOrdersData
|
||||||
|
}, dateFilter);
|
||||||
|
|
||||||
|
// Merge advanced KPIs with existing KPIs
|
||||||
|
kpis.advancedFinancialKPIs = advancedKPIs;
|
||||||
|
|
||||||
return res.json(success('Comprehensive CRM KPIs retrieved successfully', kpis));
|
return res.json(success('Comprehensive CRM KPIs retrieved successfully', kpis));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching CRM KPIs:', error);
|
console.error('Error fetching CRM KPIs:', error);
|
||||||
@ -811,6 +827,438 @@ class ReportsController {
|
|||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate advanced financial KPIs for comprehensive business analysis
|
||||||
|
*/
|
||||||
|
calculateAdvancedFinancialKPIs(data, dateFilter) {
|
||||||
|
const now = new Date();
|
||||||
|
const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||||
|
const threeMonthsAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Filter data based on date range
|
||||||
|
const filterByDate = (items, dateField = 'created_time') => {
|
||||||
|
if (!dateFilter || !dateFilter[dateField]) return items;
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDate = new Date(item[dateField]);
|
||||||
|
return itemDate >= dateFilter[dateField][Op.gte];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredDeals = filterByDate(data.deals);
|
||||||
|
const filteredInvoices = filterByDate(data.invoices);
|
||||||
|
const filteredSalesOrders = filterByDate(data.salesOrders);
|
||||||
|
const filteredPurchaseOrders = filterByDate(data.purchaseOrders);
|
||||||
|
const filteredLeads = filterByDate(data.leads);
|
||||||
|
const filteredContacts = filterByDate(data.contacts);
|
||||||
|
const filteredTasks = filterByDate(data.tasks);
|
||||||
|
|
||||||
|
// 1. Revenue Growth Rate (#1)
|
||||||
|
const revenueGrowthRate = this.calculateRevenueGrowthRate(filteredInvoices, filteredSalesOrders, oneYearAgo, sixMonthsAgo);
|
||||||
|
|
||||||
|
// 2. Gross Margin (#2)
|
||||||
|
const grossMargin = this.calculateGrossMargin(filteredSalesOrders, filteredPurchaseOrders);
|
||||||
|
|
||||||
|
// 3. Net Profit Margin (#3)
|
||||||
|
const netProfitMargin = this.calculateNetProfitMargin(filteredInvoices, filteredSalesOrders, filteredPurchaseOrders);
|
||||||
|
|
||||||
|
// 4. Operating Cash Flow (#4)
|
||||||
|
const operatingCashFlow = this.calculateOperatingCashFlow(filteredInvoices, filteredPurchaseOrders);
|
||||||
|
|
||||||
|
// 5. Cash Runway (#5)
|
||||||
|
const cashRunway = this.calculateCashRunway(filteredInvoices, filteredPurchaseOrders);
|
||||||
|
|
||||||
|
// 6. Customer Acquisition Cost (CAC) (#6)
|
||||||
|
const customerAcquisitionCost = this.calculateCustomerAcquisitionCost(filteredLeads, filteredContacts, filteredTasks);
|
||||||
|
|
||||||
|
// 7. Customer Lifetime Value (CLV) (#7)
|
||||||
|
const customerLifetimeValue = this.calculateCustomerLifetimeValue(filteredDeals, filteredInvoices, filteredContacts);
|
||||||
|
|
||||||
|
// 8. LTV-to-CAC Ratio (#8)
|
||||||
|
const ltvToCacRatio = this.calculateLTVToCACRatio(customerLifetimeValue, customerAcquisitionCost);
|
||||||
|
|
||||||
|
// 9. Net Revenue Retention (#9)
|
||||||
|
const netRevenueRetention = this.calculateNetRevenueRetention(filteredDeals, filteredInvoices, filteredContacts, oneYearAgo);
|
||||||
|
|
||||||
|
// 10. Churn Rate (#10)
|
||||||
|
const churnRate = this.calculateChurnRate(filteredContacts, filteredDeals, oneYearAgo);
|
||||||
|
|
||||||
|
// 11. Average Revenue Per Account (ARPA) (#11)
|
||||||
|
const averageRevenuePerAccount = this.calculateAverageRevenuePerAccount(filteredInvoices, filteredDeals, filteredContacts);
|
||||||
|
|
||||||
|
// 12. Burn Multiple (#12)
|
||||||
|
const burnMultiple = this.calculateBurnMultiple(filteredPurchaseOrders, filteredInvoices, filteredSalesOrders);
|
||||||
|
|
||||||
|
// 13. Sales Cycle Length (#13)
|
||||||
|
const salesCycleLength = this.calculateSalesCycleLength(filteredLeads, filteredTasks, filteredDeals);
|
||||||
|
|
||||||
|
// 15. Net Promoter Score (NPS) (#15) - Placeholder as it requires customer feedback
|
||||||
|
const netPromoterScore = this.calculateNetPromoterScore(filteredContacts, filteredDeals);
|
||||||
|
|
||||||
|
// 16. Days Sales Outstanding (DSO) (#16)
|
||||||
|
const daysSalesOutstanding = this.calculateDaysSalesOutstanding(filteredInvoices);
|
||||||
|
|
||||||
|
// 17. Growth Efficiency Ratio (#17)
|
||||||
|
const growthEfficiencyRatio = this.calculateGrowthEfficiencyRatio(filteredDeals, filteredSalesOrders, filteredContacts, oneYearAgo);
|
||||||
|
|
||||||
|
// 18. EBITDA (#18) - Simplified calculation
|
||||||
|
const ebitda = this.calculateEBITDA(filteredInvoices, filteredSalesOrders, filteredPurchaseOrders);
|
||||||
|
|
||||||
|
return {
|
||||||
|
revenueGrowthRate,
|
||||||
|
grossMargin,
|
||||||
|
netProfitMargin,
|
||||||
|
operatingCashFlow,
|
||||||
|
cashRunway,
|
||||||
|
customerAcquisitionCost,
|
||||||
|
customerLifetimeValue,
|
||||||
|
ltvToCacRatio,
|
||||||
|
netRevenueRetention,
|
||||||
|
churnRate,
|
||||||
|
averageRevenuePerAccount,
|
||||||
|
burnMultiple,
|
||||||
|
salesCycleLength,
|
||||||
|
netPromoterScore,
|
||||||
|
daysSalesOutstanding,
|
||||||
|
growthEfficiencyRatio,
|
||||||
|
ebitda
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual KPI calculation methods
|
||||||
|
|
||||||
|
calculateRevenueGrowthRate(invoices, salesOrders, oneYearAgo, sixMonthsAgo) {
|
||||||
|
const currentPeriodRevenue = this.getTotalRevenue(invoices, salesOrders, sixMonthsAgo);
|
||||||
|
const previousPeriodRevenue = this.getTotalRevenue(invoices, salesOrders, oneYearAgo, sixMonthsAgo);
|
||||||
|
|
||||||
|
if (previousPeriodRevenue === 0) return { value: 0, percentage: '0%', trend: 'stable' };
|
||||||
|
|
||||||
|
const growthRate = ((currentPeriodRevenue - previousPeriodRevenue) / previousPeriodRevenue) * 100;
|
||||||
|
return {
|
||||||
|
value: growthRate,
|
||||||
|
percentage: `${growthRate.toFixed(2)}%`,
|
||||||
|
trend: growthRate > 0 ? 'growing' : growthRate < 0 ? 'declining' : 'stable',
|
||||||
|
currentPeriodRevenue,
|
||||||
|
previousPeriodRevenue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateGrossMargin(salesOrders, purchaseOrders) {
|
||||||
|
const totalRevenue = salesOrders.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
const totalCOGS = purchaseOrders.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
|
||||||
|
if (totalRevenue === 0) return { value: 0, percentage: '0%' };
|
||||||
|
|
||||||
|
const grossMargin = ((totalRevenue - totalCOGS) / totalRevenue) * 100;
|
||||||
|
return {
|
||||||
|
value: grossMargin,
|
||||||
|
percentage: `${grossMargin.toFixed(2)}%`,
|
||||||
|
totalRevenue,
|
||||||
|
totalCOGS,
|
||||||
|
grossProfit: totalRevenue - totalCOGS
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateNetProfitMargin(invoices, salesOrders, purchaseOrders) {
|
||||||
|
const totalRevenue = this.getTotalRevenue(invoices, salesOrders);
|
||||||
|
const totalCosts = purchaseOrders.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
|
||||||
|
if (totalRevenue === 0) return { value: 0, percentage: '0%' };
|
||||||
|
|
||||||
|
const netProfitMargin = ((totalRevenue - totalCosts) / totalRevenue) * 100;
|
||||||
|
return {
|
||||||
|
value: netProfitMargin,
|
||||||
|
percentage: `${netProfitMargin.toFixed(2)}%`,
|
||||||
|
totalRevenue,
|
||||||
|
totalCosts,
|
||||||
|
netProfit: totalRevenue - totalCosts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateOperatingCashFlow(invoices, purchaseOrders) {
|
||||||
|
const cashIn = invoices
|
||||||
|
.filter(invoice => invoice.status && invoice.status.toLowerCase().includes('paid'))
|
||||||
|
.reduce((sum, invoice) => sum + (parseFloat(invoice.total) || 0), 0);
|
||||||
|
|
||||||
|
const cashOut = purchaseOrders
|
||||||
|
.filter(order => order.status && order.status.toLowerCase().includes('paid'))
|
||||||
|
.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cashIn,
|
||||||
|
cashOut,
|
||||||
|
netCashFlow: cashIn - cashOut,
|
||||||
|
cashFlowRatio: cashOut > 0 ? (cashIn / cashOut).toFixed(2) : 'N/A'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCashRunway(invoices, purchaseOrders) {
|
||||||
|
const monthlyCashIn = this.getMonthlyAverage(invoices, 'total');
|
||||||
|
const monthlyCashOut = this.getMonthlyAverage(purchaseOrders, 'total');
|
||||||
|
|
||||||
|
if (monthlyCashOut === 0) return { months: 'Infinite', status: 'positive' };
|
||||||
|
|
||||||
|
const runway = monthlyCashIn / monthlyCashOut;
|
||||||
|
return {
|
||||||
|
months: runway.toFixed(1),
|
||||||
|
status: runway > 12 ? 'healthy' : runway > 6 ? 'moderate' : 'critical',
|
||||||
|
monthlyCashIn,
|
||||||
|
monthlyCashOut
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCustomerAcquisitionCost(leads, contacts, tasks) {
|
||||||
|
const marketingSpend = tasks
|
||||||
|
.filter(task => task.subject && task.subject.toLowerCase().includes('marketing'))
|
||||||
|
.length * 100; // Assuming $100 per marketing task
|
||||||
|
|
||||||
|
const newCustomers = contacts.filter(contact => {
|
||||||
|
const contactDate = new Date(contact.created_time);
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
return contactDate >= thirtyDaysAgo;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (newCustomers === 0) return { value: 0, status: 'no_new_customers' };
|
||||||
|
|
||||||
|
const cac = marketingSpend / newCustomers;
|
||||||
|
return {
|
||||||
|
value: cac,
|
||||||
|
marketingSpend,
|
||||||
|
newCustomers,
|
||||||
|
status: cac < 100 ? 'efficient' : cac < 500 ? 'moderate' : 'expensive'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCustomerLifetimeValue(deals, invoices, contacts) {
|
||||||
|
const totalRevenue = this.getTotalRevenue(invoices, []);
|
||||||
|
const totalCustomers = contacts.length;
|
||||||
|
|
||||||
|
if (totalCustomers === 0) return { value: 0, status: 'no_customers' };
|
||||||
|
|
||||||
|
const clv = totalRevenue / totalCustomers;
|
||||||
|
return {
|
||||||
|
value: clv,
|
||||||
|
totalRevenue,
|
||||||
|
totalCustomers,
|
||||||
|
status: clv > 1000 ? 'high_value' : clv > 500 ? 'medium_value' : 'low_value'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateLTVToCACRatio(clv, cac) {
|
||||||
|
if (cac.value === 0) return { ratio: 'N/A', status: 'no_cac_data' };
|
||||||
|
|
||||||
|
const ratio = clv.value / cac.value;
|
||||||
|
return {
|
||||||
|
ratio: ratio.toFixed(2),
|
||||||
|
clv: clv.value,
|
||||||
|
cac: cac.value,
|
||||||
|
status: ratio > 3 ? 'excellent' : ratio > 1 ? 'good' : 'poor'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateNetRevenueRetention(deals, invoices, contacts, oneYearAgo) {
|
||||||
|
const currentCustomers = contacts.length;
|
||||||
|
const recurringRevenue = invoices
|
||||||
|
.filter(invoice => {
|
||||||
|
const invoiceDate = new Date(invoice.created_time);
|
||||||
|
return invoiceDate >= oneYearAgo;
|
||||||
|
})
|
||||||
|
.reduce((sum, invoice) => sum + (parseFloat(invoice.total) || 0), 0);
|
||||||
|
|
||||||
|
if (currentCustomers === 0) return { percentage: '0%', status: 'no_customers' };
|
||||||
|
|
||||||
|
const nrr = (recurringRevenue / currentCustomers) * 100;
|
||||||
|
return {
|
||||||
|
percentage: `${nrr.toFixed(2)}%`,
|
||||||
|
recurringRevenue,
|
||||||
|
currentCustomers,
|
||||||
|
status: nrr > 100 ? 'growing' : nrr > 90 ? 'stable' : 'declining'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateChurnRate(contacts, deals, oneYearAgo) {
|
||||||
|
const totalCustomers = contacts.length;
|
||||||
|
const lostCustomers = deals
|
||||||
|
.filter(deal => {
|
||||||
|
const dealDate = new Date(deal.created_time);
|
||||||
|
return dealDate >= oneYearAgo && deal.stage && deal.stage.toLowerCase().includes('lost');
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (totalCustomers === 0) return { percentage: '0%', status: 'no_customers' };
|
||||||
|
|
||||||
|
const churnRate = (lostCustomers / totalCustomers) * 100;
|
||||||
|
return {
|
||||||
|
percentage: `${churnRate.toFixed(2)}%`,
|
||||||
|
lostCustomers,
|
||||||
|
totalCustomers,
|
||||||
|
status: churnRate < 5 ? 'excellent' : churnRate < 10 ? 'good' : 'needs_attention'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateAverageRevenuePerAccount(invoices, deals, contacts) {
|
||||||
|
const totalRevenue = this.getTotalRevenue(invoices, []);
|
||||||
|
const totalAccounts = contacts.length;
|
||||||
|
|
||||||
|
if (totalAccounts === 0) return { value: 0, status: 'no_accounts' };
|
||||||
|
|
||||||
|
const arpa = totalRevenue / totalAccounts;
|
||||||
|
return {
|
||||||
|
value: arpa,
|
||||||
|
totalRevenue,
|
||||||
|
totalAccounts,
|
||||||
|
status: arpa > 1000 ? 'high' : arpa > 500 ? 'medium' : 'low'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateBurnMultiple(purchaseOrders, invoices, salesOrders) {
|
||||||
|
const totalBurn = purchaseOrders.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
const newARR = this.getTotalRevenue(invoices, salesOrders);
|
||||||
|
|
||||||
|
if (newARR === 0) return { multiple: 'N/A', status: 'no_revenue' };
|
||||||
|
|
||||||
|
const multiple = totalBurn / newARR;
|
||||||
|
return {
|
||||||
|
multiple: multiple.toFixed(2),
|
||||||
|
totalBurn,
|
||||||
|
newARR,
|
||||||
|
status: multiple < 1 ? 'efficient' : multiple < 2 ? 'moderate' : 'inefficient'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateSalesCycleLength(leads, tasks, deals) {
|
||||||
|
const cycleLengths = [];
|
||||||
|
|
||||||
|
deals.forEach(deal => {
|
||||||
|
const dealDate = new Date(deal.created_time);
|
||||||
|
const relatedLeads = leads.filter(lead =>
|
||||||
|
lead.company === deal.account_name ||
|
||||||
|
lead.email === deal.contact_name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relatedLeads.length > 0) {
|
||||||
|
const leadDate = new Date(relatedLeads[0].created_time);
|
||||||
|
const cycleLength = Math.ceil((dealDate - leadDate) / (1000 * 60 * 60 * 24));
|
||||||
|
if (cycleLength > 0) cycleLengths.push(cycleLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cycleLengths.length === 0) return { averageDays: 0, status: 'no_data' };
|
||||||
|
|
||||||
|
const averageDays = cycleLengths.reduce((sum, days) => sum + days, 0) / cycleLengths.length;
|
||||||
|
return {
|
||||||
|
averageDays: Math.round(averageDays),
|
||||||
|
totalCycles: cycleLengths.length,
|
||||||
|
status: averageDays < 30 ? 'fast' : averageDays < 90 ? 'moderate' : 'slow'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateNetPromoterScore(contacts, deals) {
|
||||||
|
// Placeholder implementation - would need customer feedback data
|
||||||
|
const totalCustomers = contacts.length;
|
||||||
|
const promoters = deals
|
||||||
|
.filter(deal => deal.stage && deal.stage.toLowerCase().includes('closed won'))
|
||||||
|
.length;
|
||||||
|
|
||||||
|
if (totalCustomers === 0) return { score: 0, status: 'no_customers' };
|
||||||
|
|
||||||
|
const nps = (promoters / totalCustomers) * 100;
|
||||||
|
return {
|
||||||
|
score: Math.round(nps),
|
||||||
|
promoters,
|
||||||
|
totalCustomers,
|
||||||
|
status: nps > 50 ? 'excellent' : nps > 0 ? 'good' : 'needs_improvement',
|
||||||
|
note: 'Based on deal closure rate - actual NPS requires customer surveys'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateDaysSalesOutstanding(invoices) {
|
||||||
|
const unpaidInvoices = invoices.filter(invoice =>
|
||||||
|
invoice.status && !invoice.status.toLowerCase().includes('paid')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unpaidInvoices.length === 0) return { days: 0, status: 'no_outstanding' };
|
||||||
|
|
||||||
|
const totalOutstanding = unpaidInvoices.reduce((sum, invoice) =>
|
||||||
|
sum + (parseFloat(invoice.total) || 0), 0);
|
||||||
|
|
||||||
|
const averageDailySales = this.getTotalRevenue(invoices, []) / 365;
|
||||||
|
|
||||||
|
if (averageDailySales === 0) return { days: 0, status: 'no_sales' };
|
||||||
|
|
||||||
|
const dso = totalOutstanding / averageDailySales;
|
||||||
|
return {
|
||||||
|
days: Math.round(dso),
|
||||||
|
totalOutstanding,
|
||||||
|
averageDailySales,
|
||||||
|
status: dso < 30 ? 'excellent' : dso < 60 ? 'good' : 'needs_attention'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateGrowthEfficiencyRatio(deals, salesOrders, contacts, oneYearAgo) {
|
||||||
|
const newRevenue = this.getTotalRevenue([], salesOrders, oneYearAgo);
|
||||||
|
const churnedCustomers = contacts.filter(contact => {
|
||||||
|
const contactDate = new Date(contact.created_time);
|
||||||
|
return contactDate < oneYearAgo;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const churnCost = churnedCustomers * 100; // Assuming $100 cost per churned customer
|
||||||
|
|
||||||
|
if (churnCost === 0) return { ratio: 'N/A', status: 'no_churn' };
|
||||||
|
|
||||||
|
const ratio = newRevenue / churnCost;
|
||||||
|
return {
|
||||||
|
ratio: ratio.toFixed(2),
|
||||||
|
newRevenue,
|
||||||
|
churnCost,
|
||||||
|
churnedCustomers,
|
||||||
|
status: ratio > 2 ? 'efficient' : ratio > 1 ? 'moderate' : 'inefficient'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateEBITDA(invoices, salesOrders, purchaseOrders) {
|
||||||
|
const totalRevenue = this.getTotalRevenue(invoices, salesOrders);
|
||||||
|
const totalCosts = purchaseOrders.reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
|
||||||
|
// Simplified EBITDA calculation (would need more detailed financial data)
|
||||||
|
const ebitda = totalRevenue - totalCosts;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: ebitda,
|
||||||
|
totalRevenue,
|
||||||
|
totalCosts,
|
||||||
|
margin: totalRevenue > 0 ? ((ebitda / totalRevenue) * 100).toFixed(2) + '%' : '0%',
|
||||||
|
status: ebitda > 0 ? 'profitable' : 'loss'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
getTotalRevenue(invoices, salesOrders, startDate = null, endDate = null) {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const filterByDate = (items) => {
|
||||||
|
if (!startDate) return items;
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDate = new Date(item.created_time);
|
||||||
|
return itemDate >= startDate && (!endDate || itemDate < endDate);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
total += filterByDate(invoices).reduce((sum, invoice) => sum + (parseFloat(invoice.total) || 0), 0);
|
||||||
|
total += filterByDate(salesOrders).reduce((sum, order) => sum + (parseFloat(order.total) || 0), 0);
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthlyAverage(items, field) {
|
||||||
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
|
const total = items.reduce((sum, item) => sum + (parseFloat(item[field]) || 0), 0);
|
||||||
|
const months = 12; // Assuming 12 months of data
|
||||||
|
|
||||||
|
return total / months;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportsController = new ReportsController();
|
const reportsController = new ReportsController();
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const {
|
|||||||
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
||||||
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
||||||
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
||||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs
|
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs, getCrmCounts
|
||||||
} = require('../controllers/integrationController');
|
} = require('../controllers/integrationController');
|
||||||
const auth = require('../middlewares/auth');
|
const auth = require('../middlewares/auth');
|
||||||
const ZohoHandler = require('../../integrations/zoho/handler');
|
const ZohoHandler = require('../../integrations/zoho/handler');
|
||||||
@ -158,6 +158,13 @@ const invoicesSchema = Joi.object({
|
|||||||
router.get('/zoho/crm/invoices', auth, validate(invoicesSchema), getInvoices);
|
router.get('/zoho/crm/invoices', auth, validate(invoicesSchema), getInvoices);
|
||||||
router.get('/zoho/crm/contacts', auth, validate(invoicesSchema), getContacts);
|
router.get('/zoho/crm/contacts', auth, validate(invoicesSchema), getContacts);
|
||||||
|
|
||||||
|
// Get Zoho CRM counts for all modules
|
||||||
|
const crmCountsSchema = Joi.object({
|
||||||
|
provider: Joi.string().valid('zoho').required()
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/zoho/crm/counts', auth, validate(crmCountsSchema), getCrmCounts);
|
||||||
|
|
||||||
// Zoho People specific routes
|
// Zoho People specific routes
|
||||||
const departmentsSchema = Joi.object({
|
const departmentsSchema = Joi.object({
|
||||||
provider: Joi.string().valid('zoho').required(),
|
provider: Joi.string().valid('zoho').required(),
|
||||||
|
|||||||
@ -41,15 +41,17 @@ class ZohoBulkReadRepository {
|
|||||||
* Bulk insert data for a specific module
|
* Bulk insert data for a specific module
|
||||||
* @param {string} module - Module name
|
* @param {string} module - Module name
|
||||||
* @param {Array} data - Array of records to insert
|
* @param {Array} data - Array of records to insert
|
||||||
|
* @param {Object} transaction - Sequelize transaction (optional)
|
||||||
* @returns {Promise<Array>} Inserted records
|
* @returns {Promise<Array>} Inserted records
|
||||||
*/
|
*/
|
||||||
async bulkInsert(module, data) {
|
async bulkInsert(module, data, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const model = this.getModel(module);
|
const model = this.getModel(module);
|
||||||
console.log(`💾 Bulk inserting ${data.length} records for ${module}`);
|
console.log(`💾 Bulk inserting ${data.length} records for ${module}`);
|
||||||
|
|
||||||
const result = await model.bulkCreate(data, {
|
const result = await model.bulkCreate(data, {
|
||||||
validate: true
|
validate: true,
|
||||||
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Successfully inserted ${result.length} records for ${module}`);
|
console.log(`✅ Successfully inserted ${result.length} records for ${module}`);
|
||||||
@ -64,20 +66,39 @@ class ZohoBulkReadRepository {
|
|||||||
* Clear existing data for a user and module
|
* Clear existing data for a user and module
|
||||||
* @param {string} userId - User UUID
|
* @param {string} userId - User UUID
|
||||||
* @param {string} module - Module name
|
* @param {string} module - Module name
|
||||||
* @param {string} jobId - Bulk job ID (optional, used for logging)
|
* @param {Object} transaction - Sequelize transaction (optional)
|
||||||
* @returns {Promise<number>} Number of deleted records
|
* @returns {Promise<number>} Number of deleted records
|
||||||
*/
|
*/
|
||||||
async clearUserData(userId, module, jobId = null) {
|
async clearUserData(userId, module, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const model = this.getModel(module);
|
const model = this.getModel(module);
|
||||||
console.log(`🗑️ Clearing ALL existing data for user ${userId}, module ${module}${jobId ? ` (new job: ${jobId})` : ''}`);
|
console.log(`🗑️ Clearing ALL existing data for user ${userId}, module ${module}`);
|
||||||
|
|
||||||
|
// First, let's see what records exist before deletion
|
||||||
|
const existingRecords = await model.findAll({
|
||||||
|
where: {
|
||||||
|
user_uuid: userId,
|
||||||
|
provider: 'zoho'
|
||||||
|
},
|
||||||
|
attributes: ['internal_id', 'zoho_id', 'user_uuid', 'provider', 'bulk_job_id'],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔍 Found ${existingRecords.length} existing records to delete:`);
|
||||||
|
existingRecords.slice(0, 5).forEach(record => {
|
||||||
|
console.log(` - ID: ${record.internal_id}, ZohoID: ${record.zoho_id}, JobID: ${record.bulk_job_id}`);
|
||||||
|
});
|
||||||
|
if (existingRecords.length > 5) {
|
||||||
|
console.log(` ... and ${existingRecords.length - 5} more records`);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await model.destroy({
|
const result = await model.destroy({
|
||||||
where: {
|
where: {
|
||||||
user_uuid: userId,
|
user_uuid: userId,
|
||||||
provider: 'zoho'
|
provider: 'zoho'
|
||||||
// Removed bulk_job_id filter to clear ALL data for the user and module
|
// NOTE: Intentionally NOT filtering by job_id to clear ALL data for user+module
|
||||||
}
|
},
|
||||||
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Cleared ${result} existing records for ${module}`);
|
console.log(`✅ Cleared ${result} existing records for ${module}`);
|
||||||
@ -121,9 +142,10 @@ class ZohoBulkReadRepository {
|
|||||||
* Get count of records for a user and module
|
* Get count of records for a user and module
|
||||||
* @param {string} userId - User UUID
|
* @param {string} userId - User UUID
|
||||||
* @param {string} module - Module name
|
* @param {string} module - Module name
|
||||||
|
* @param {Object} transaction - Sequelize transaction (optional)
|
||||||
* @returns {Promise<number>} Record count
|
* @returns {Promise<number>} Record count
|
||||||
*/
|
*/
|
||||||
async getUserDataCount(userId, module) {
|
async getUserDataCount(userId, module, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const model = this.getModel(module);
|
const model = this.getModel(module);
|
||||||
|
|
||||||
@ -131,7 +153,8 @@ class ZohoBulkReadRepository {
|
|||||||
where: {
|
where: {
|
||||||
user_uuid: userId,
|
user_uuid: userId,
|
||||||
provider: 'zoho'
|
provider: 'zoho'
|
||||||
}
|
},
|
||||||
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
|
|||||||
@ -51,10 +51,8 @@ class ZohoClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios(url, config);
|
const response = await axios(url, config);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.log('error in makeRequest',JSON.stringify(error))
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
await this.refreshToken();
|
await this.refreshToken();
|
||||||
const newTokens = await this.getTokens();
|
const newTokens = await this.getTokens();
|
||||||
@ -130,6 +128,57 @@ class ZohoClient {
|
|||||||
return ZohoMapper.mapApiResponse(response, 'invoices');
|
return ZohoMapper.mapApiResponse(response, 'invoices');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAccounts(params = {}) {
|
||||||
|
const response = await this.makeRequest('/crm/v2/Accounts', { params });
|
||||||
|
return ZohoMapper.mapApiResponse(response, 'accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get counts for all CRM modules in a single call
|
||||||
|
async getCrmCounts() {
|
||||||
|
const modules = ['Accounts', 'Leads', 'Tasks', 'Invoices', 'Sales_Orders', 'Purchase_Orders', 'Deals', 'Contacts', 'Vendors'];
|
||||||
|
const counts = {};
|
||||||
|
|
||||||
|
// Use Promise.allSettled to handle all requests concurrently and continue even if some fail
|
||||||
|
const promises = modules.map(async (module) => {
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest(`/crm/v2.1/${module}/actions/count`);
|
||||||
|
return { module: module.toLowerCase(), count: response.count || 0, success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting count for ${module}:`, error.message);
|
||||||
|
return { module: module.toLowerCase(), count: 0, success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const { module, count, success, error } = result.value;
|
||||||
|
counts[module] = {
|
||||||
|
count,
|
||||||
|
success,
|
||||||
|
...(error && { error })
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const module = modules[index].toLowerCase();
|
||||||
|
counts[module] = {
|
||||||
|
count: 0,
|
||||||
|
success: false,
|
||||||
|
error: result.reason?.message || 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: counts,
|
||||||
|
info: {
|
||||||
|
totalModules: modules.length,
|
||||||
|
successfulModules: Object.values(counts).filter(m => m.success).length,
|
||||||
|
failedModules: Object.values(counts).filter(m => !m.success).length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Zoho People methods
|
// Zoho People methods
|
||||||
async getEmployees(params = {}) {
|
async getEmployees(params = {}) {
|
||||||
const response = await this.makeRequest('/people/api/v1/employees', { params }, 'people');
|
const response = await this.makeRequest('/people/api/v1/employees', { params }, 'people');
|
||||||
@ -393,7 +442,7 @@ class ZohoClient {
|
|||||||
|
|
||||||
async getAllProjectIssues(portalId, params = {}) {
|
async getAllProjectIssues(portalId, params = {}) {
|
||||||
const response = await this.makeRequest(`/api/v3/portal/${portalId}/issues`, { params }, 'projects');
|
const response = await this.makeRequest(`/api/v3/portal/${portalId}/issues`, { params }, 'projects');
|
||||||
console.log('issues response i got',response)
|
// console.log('issues response i got',response)
|
||||||
return ZohoMapper.mapApiResponse(response, 'issues');
|
return ZohoMapper.mapApiResponse(response, 'issues');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -427,9 +427,9 @@ class ZohoHandler {
|
|||||||
// Verify and decode the JWT token using the same service
|
// Verify and decode the JWT token using the same service
|
||||||
const decoded = jwtService.verify(decryptedToken);
|
const decoded = jwtService.verify(decryptedToken);
|
||||||
|
|
||||||
// Get user data from database using the user ID from token
|
// Get user data from database using the user UUID from token
|
||||||
const userRepository = require('../../data/repositories/userRepository');
|
const userRepository = require('../../data/repositories/userRepository');
|
||||||
const user = await userRepository.findById(decoded.id);
|
const user = await userRepository.findByUuid(decoded.uuid);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
@ -538,12 +538,50 @@ class ZohoHandler {
|
|||||||
const mappedData = csvService.parseCsvData(csvData, module, userId, job_id);
|
const mappedData = csvService.parseCsvData(csvData, module, userId, job_id);
|
||||||
console.log(`🔄 Mapped ${mappedData.length} records for database insertion`);
|
console.log(`🔄 Mapped ${mappedData.length} records for database insertion`);
|
||||||
|
|
||||||
// Clear existing data for this job
|
// Use transaction to ensure atomicity of delete + insert operations
|
||||||
await ZohoBulkReadRepository.clearUserData(userId, module, job_id);
|
const sequelize = require('../../db/pool');
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
let insertedRecords = []; // Declare outside try block for scope
|
||||||
|
|
||||||
// Bulk insert data
|
try {
|
||||||
const insertedRecords = await ZohoBulkReadRepository.bulkInsert(module, mappedData);
|
// Clear existing data for this user and module (REPLACE strategy)
|
||||||
console.log(`✅ Successfully inserted ${insertedRecords.length} records`);
|
console.log(`🗑️ Starting data cleanup for user ${userId}, module ${module}`);
|
||||||
|
|
||||||
|
// Check count before deletion
|
||||||
|
const beforeCount = await ZohoBulkReadRepository.getUserDataCount(userId, module, transaction);
|
||||||
|
console.log(`📊 Records before cleanup: ${beforeCount}`);
|
||||||
|
|
||||||
|
const deletedCount = await ZohoBulkReadRepository.clearUserData(userId, module, transaction);
|
||||||
|
console.log(`🗑️ Deleted ${deletedCount} existing records for user ${userId}, module ${module}`);
|
||||||
|
|
||||||
|
// Verify data is cleared by checking count
|
||||||
|
const remainingCount = await ZohoBulkReadRepository.getUserDataCount(userId, module, transaction);
|
||||||
|
console.log(`🔍 Remaining records after cleanup: ${remainingCount}`);
|
||||||
|
|
||||||
|
if (remainingCount > 0) {
|
||||||
|
console.warn(`⚠️ Warning: ${remainingCount} records still exist after cleanup!`);
|
||||||
|
console.warn(`⚠️ This indicates the delete operation may not have worked properly`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk insert new data
|
||||||
|
console.log(`📥 Starting bulk insert of ${mappedData.length} new records`);
|
||||||
|
insertedRecords = await ZohoBulkReadRepository.bulkInsert(module, mappedData, transaction);
|
||||||
|
console.log(`✅ Successfully inserted ${insertedRecords.length} records`);
|
||||||
|
|
||||||
|
// Verify final count
|
||||||
|
const finalCount = await ZohoBulkReadRepository.getUserDataCount(userId, module, transaction);
|
||||||
|
console.log(`📊 Final record count for user ${userId}, module ${module}: ${finalCount}`);
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
await transaction.commit();
|
||||||
|
console.log(`✅ Transaction committed successfully`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback transaction on error
|
||||||
|
await transaction.rollback();
|
||||||
|
console.error(`❌ Transaction rolled back due to error:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Update job status
|
// Update job status
|
||||||
await ZohoBulkReadRepository.updateBulkReadJob(job_id, {
|
await ZohoBulkReadRepository.updateBulkReadJob(job_id, {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user