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 = {
|
||||
getData, getServices, getResources, getPortals, getAllProjects, getAllProjectTasks,
|
||||
getAllProjectTaskLists, getAllProjectIssues, getAllProjectPhases, getSalesOrders,
|
||||
@ -1099,5 +1117,5 @@ module.exports = {
|
||||
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
||||
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
||||
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs
|
||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs, getCrmCounts
|
||||
};
|
||||
|
||||
@ -96,6 +96,22 @@ class ReportsController {
|
||||
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));
|
||||
} catch (error) {
|
||||
console.error('Error fetching CRM KPIs:', error);
|
||||
@ -811,6 +827,438 @@ class ReportsController {
|
||||
|
||||
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();
|
||||
|
||||
@ -8,7 +8,7 @@ const {
|
||||
getExpenses, getBankAccounts, getBankTransactions, getReports, getBooksSalesOrders,
|
||||
getBooksPurchaseOrders, getContacts, getBooksContacts, getBooksInvoices, getEmployeeForms, getEmployeeById,
|
||||
getAttendanceEntries, getShiftConfiguration, getLeaveData, getGoalsData, getPerformanceData,
|
||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs
|
||||
getUserReport, getLeaveTrackerReport, getHolidays, scheduleBulkReadJobs, getCrmCounts
|
||||
} = require('../controllers/integrationController');
|
||||
const auth = require('../middlewares/auth');
|
||||
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/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
|
||||
const departmentsSchema = Joi.object({
|
||||
provider: Joi.string().valid('zoho').required(),
|
||||
|
||||
@ -41,15 +41,17 @@ class ZohoBulkReadRepository {
|
||||
* Bulk insert data for a specific module
|
||||
* @param {string} module - Module name
|
||||
* @param {Array} data - Array of records to insert
|
||||
* @param {Object} transaction - Sequelize transaction (optional)
|
||||
* @returns {Promise<Array>} Inserted records
|
||||
*/
|
||||
async bulkInsert(module, data) {
|
||||
async bulkInsert(module, data, transaction = null) {
|
||||
try {
|
||||
const model = this.getModel(module);
|
||||
console.log(`💾 Bulk inserting ${data.length} records for ${module}`);
|
||||
|
||||
const result = await model.bulkCreate(data, {
|
||||
validate: true
|
||||
validate: true,
|
||||
transaction
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully inserted ${result.length} records for ${module}`);
|
||||
@ -64,20 +66,39 @@ class ZohoBulkReadRepository {
|
||||
* Clear existing data for a user and module
|
||||
* @param {string} userId - User UUID
|
||||
* @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
|
||||
*/
|
||||
async clearUserData(userId, module, jobId = null) {
|
||||
async clearUserData(userId, module, transaction = null) {
|
||||
try {
|
||||
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({
|
||||
where: {
|
||||
user_uuid: userId,
|
||||
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}`);
|
||||
@ -121,9 +142,10 @@ class ZohoBulkReadRepository {
|
||||
* Get count of records for a user and module
|
||||
* @param {string} userId - User UUID
|
||||
* @param {string} module - Module name
|
||||
* @param {Object} transaction - Sequelize transaction (optional)
|
||||
* @returns {Promise<number>} Record count
|
||||
*/
|
||||
async getUserDataCount(userId, module) {
|
||||
async getUserDataCount(userId, module, transaction = null) {
|
||||
try {
|
||||
const model = this.getModel(module);
|
||||
|
||||
@ -131,7 +153,8 @@ class ZohoBulkReadRepository {
|
||||
where: {
|
||||
user_uuid: userId,
|
||||
provider: 'zoho'
|
||||
}
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
return count;
|
||||
|
||||
@ -51,10 +51,8 @@ class ZohoClient {
|
||||
|
||||
try {
|
||||
const response = await axios(url, config);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// console.log('error in makeRequest',JSON.stringify(error))
|
||||
if (error.response?.status === 401) {
|
||||
await this.refreshToken();
|
||||
const newTokens = await this.getTokens();
|
||||
@ -130,6 +128,57 @@ class ZohoClient {
|
||||
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
|
||||
async getEmployees(params = {}) {
|
||||
const response = await this.makeRequest('/people/api/v1/employees', { params }, 'people');
|
||||
@ -393,7 +442,7 @@ class ZohoClient {
|
||||
|
||||
async getAllProjectIssues(portalId, params = {}) {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@ -427,9 +427,9 @@ class ZohoHandler {
|
||||
// Verify and decode the JWT token using the same service
|
||||
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 user = await userRepository.findById(decoded.id);
|
||||
const user = await userRepository.findByUuid(decoded.uuid);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
@ -538,13 +538,51 @@ class ZohoHandler {
|
||||
const mappedData = csvService.parseCsvData(csvData, module, userId, job_id);
|
||||
console.log(`🔄 Mapped ${mappedData.length} records for database insertion`);
|
||||
|
||||
// Clear existing data for this job
|
||||
await ZohoBulkReadRepository.clearUserData(userId, module, job_id);
|
||||
// Use transaction to ensure atomicity of delete + insert operations
|
||||
const sequelize = require('../../db/pool');
|
||||
const transaction = await sequelize.transaction();
|
||||
let insertedRecords = []; // Declare outside try block for scope
|
||||
|
||||
// Bulk insert data
|
||||
const insertedRecords = await ZohoBulkReadRepository.bulkInsert(module, mappedData);
|
||||
try {
|
||||
// Clear existing data for this user and module (REPLACE strategy)
|
||||
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
|
||||
await ZohoBulkReadRepository.updateBulkReadJob(job_id, {
|
||||
status: 'completed',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user