- Added SimpleAnalyticsPopulator class for populating project, employee, customer analytics, and daily metrics. - Integrated MySQL database connections for tilbudgivern and ordrestyring_local databases. - Developed sync_ordrestyring_data_old.sh script for comprehensive data synchronization from Ordrestyring API. - Created test scripts for analytics API and services to validate functionality and data integrity. - Introduced FixedHistoricalQuoteCalculator and HistoricalQuoteCalculator for generating quotes based on historical data. - Implemented vary-profit-margins.js to introduce variability in profit margins based on project size and customer type.
458 lines
21 KiB
JavaScript
458 lines
21 KiB
JavaScript
const mysql = require('mysql2/promise');
|
|
|
|
class CaseAnalyticsService {
|
|
constructor() {
|
|
this.dbConfig = {
|
|
host: 'localhost',
|
|
user: 'analytics_user',
|
|
password: 'Analytics2023',
|
|
database: 'tilbudgivern'
|
|
};
|
|
}
|
|
|
|
async getConnection() {
|
|
return await mysql.createConnection(this.dbConfig);
|
|
}
|
|
|
|
// ====================
|
|
// CORE ANALYTICS METHODS
|
|
// ====================
|
|
|
|
async getSingleProjectAnalytics(projectId) {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
// Get detailed project analytics
|
|
const [project] = await connection.execute(`
|
|
SELECT
|
|
pa.*,
|
|
CASE
|
|
WHEN pa.is_active = 1 THEN 'Active'
|
|
ELSE 'Completed'
|
|
END as status_text
|
|
FROM project_analytics pa
|
|
WHERE pa.project_id = ?
|
|
`, [projectId]);
|
|
|
|
if (project.length === 0) {
|
|
throw new Error(`Project ${projectId} not found`);
|
|
}
|
|
|
|
return {
|
|
project: project[0],
|
|
analysis: {
|
|
profitability: project[0].profit_margin > 35 ? 'Excellent' :
|
|
project[0].profit_margin > 25 ? 'Good' :
|
|
project[0].profit_margin > 15 ? 'Fair' : 'Poor',
|
|
efficiency: project[0].total_hours > 0 ? project[0].total_quote_value / project[0].total_hours : 0,
|
|
accuracy: project[0].quote_accuracy_percentage > 90 ? 'Excellent' :
|
|
project[0].quote_accuracy_percentage > 80 ? 'Good' :
|
|
project[0].quote_accuracy_percentage > 70 ? 'Fair' : 'Poor'
|
|
},
|
|
recommendations: this.generateProjectRecommendations(project[0])
|
|
};
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
generateProjectRecommendations(project) {
|
|
const recommendations = [];
|
|
|
|
if (project.profit_margin < 25) {
|
|
recommendations.push('Consider increasing markup or reducing material costs');
|
|
}
|
|
if (project.quote_accuracy_percentage < 85) {
|
|
recommendations.push('Review estimation process to improve accuracy');
|
|
}
|
|
if (project.total_hours > 0 && (project.total_quote_value / project.total_hours) < 1500) {
|
|
recommendations.push('Focus on higher value activities to improve hourly rate');
|
|
}
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
async generateProjectAnalytics(projectId = null) {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
let whereClause = projectId ? `WHERE pa.project_id = ${projectId}` : 'LIMIT 100';
|
|
|
|
const [projectMetrics] = await connection.execute(`
|
|
SELECT
|
|
pa.project_id,
|
|
pa.project_name,
|
|
oc.customer_number,
|
|
oc.total_amount as quoted_amount,
|
|
oc.start_date,
|
|
oc.end_date,
|
|
-- Timer metrics
|
|
(SELECT SUM(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number) as actual_hours,
|
|
(SELECT COUNT(*) FROM ordrestyring_hours WHERE case_number = oc.case_number) as hour_entries,
|
|
(SELECT COUNT(DISTINCT employee_id) FROM ordrestyring_hours WHERE case_number = oc.case_number) as employees_count,
|
|
(SELECT AVG(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number) as avg_daily_hours,
|
|
-- Material metrics
|
|
(SELECT SUM(total_cost) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) as material_cost,
|
|
(SELECT SUM(total_sales) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) as material_sales,
|
|
(SELECT COUNT(*) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) as material_entries,
|
|
(SELECT COUNT(DISTINCT supplier) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) as suppliers_count,
|
|
-- Calculated KPIs
|
|
CASE
|
|
WHEN (SELECT SUM(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number) > 0
|
|
THEN (SELECT SUM(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number) * 450
|
|
ELSE 0
|
|
END as estimated_labor_cost,
|
|
CASE
|
|
WHEN (SELECT SUM(total_cost) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) > 0
|
|
AND (SELECT SUM(total_sales) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) > 0
|
|
THEN ((SELECT SUM(total_sales) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) -
|
|
(SELECT SUM(total_cost) FROM ordrestyring_case_materials WHERE case_number = oc.case_number)) /
|
|
(SELECT SUM(total_cost) FROM ordrestyring_case_materials WHERE case_number = oc.case_number) * 100
|
|
ELSE 0
|
|
END as material_margin_percent,
|
|
-- Date calculations
|
|
CASE
|
|
WHEN oc.start_date IS NOT NULL AND oc.end_date IS NOT NULL
|
|
THEN DATEDIFF(FROM_UNIXTIME(oc.end_date), FROM_UNIXTIME(oc.start_date))
|
|
ELSE NULL
|
|
END as project_duration_days,
|
|
DATE(FROM_UNIXTIME(oc.start_date)) as start_date_formatted,
|
|
DATE(FROM_UNIXTIME(oc.end_date)) as end_date_formatted
|
|
FROM ordrestyring_cases oc
|
|
${whereClause}
|
|
HAVING actual_hours > 0 OR material_cost > 0
|
|
ORDER BY oc.case_number DESC
|
|
`);
|
|
|
|
// Calculate additional derived metrics
|
|
const enrichedMetrics = projectMetrics.map(project => {
|
|
const laborCost = Number(project.estimated_labor_cost) || 0;
|
|
const materialSales = Number(project.material_sales) || 0;
|
|
const materialCost = Number(project.material_cost) || 0;
|
|
const totalProjectValue = laborCost + materialSales;
|
|
const materialProfit = materialSales - materialCost;
|
|
const totalProfit = laborCost * 0.2 + materialProfit; // Assume 20% labor margin
|
|
const profitMargin = totalProjectValue > 0 ? (totalProfit / totalProjectValue) * 100 : 0;
|
|
|
|
return {
|
|
...project,
|
|
total_project_value: Math.round(totalProjectValue),
|
|
material_profit: Math.round(materialProfit),
|
|
estimated_total_profit: Math.round(totalProfit),
|
|
overall_profit_margin: Math.round(profitMargin * 10) / 10,
|
|
hours_per_day: project.project_duration_days > 0 ?
|
|
Math.round((project.actual_hours / project.project_duration_days) * 10) / 10 : null,
|
|
hourly_rate_achieved: project.actual_hours > 0 ?
|
|
Math.round((totalProjectValue / project.actual_hours) * 10) / 10 : null
|
|
};
|
|
});
|
|
|
|
return enrichedMetrics;
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async getOverallKPIs() {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
// Get KPIs from analytics tables
|
|
const [totals] = await connection.execute(`
|
|
SELECT
|
|
COUNT(*) as totalProjects,
|
|
COUNT(DISTINCT customer_id) as activeCustomers,
|
|
SUM(total_quote_value) as totalRevenue,
|
|
COUNT(DISTINCT employee_id) as activeEmployees,
|
|
AVG(profit_margin) as avgProfitMargin,
|
|
SUM(total_hours) as totalHours
|
|
FROM project_analytics
|
|
WHERE project_start_date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
|
`);
|
|
|
|
const kpis = totals[0];
|
|
|
|
return {
|
|
totalProjects: kpis.totalProjects || 0,
|
|
totalRevenue: kpis.totalRevenue || 0,
|
|
activeCustomers: kpis.activeCustomers || 0,
|
|
activeEmployees: kpis.activeEmployees || 0,
|
|
avgProfitMargin: kpis.avgProfitMargin || 0,
|
|
totalHours: kpis.totalHours || 0,
|
|
avgProjectValue: kpis.totalProjects > 0 ? (kpis.totalRevenue / kpis.totalProjects) : 0,
|
|
lastUpdated: new Date().toISOString()
|
|
};
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async getEmployeePerformanceMetrics() {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
const [performance] = await connection.execute(`
|
|
SELECT
|
|
oh.employee_id,
|
|
ou.full_name,
|
|
COUNT(DISTINCT oh.case_number) as projects_worked,
|
|
COUNT(*) as total_entries,
|
|
SUM(oh.duration_hours) as total_hours,
|
|
AVG(oh.duration_hours) as avg_daily_hours,
|
|
MIN(DATE(FROM_UNIXTIME(oh.start_time))) as first_project_date,
|
|
MAX(DATE(FROM_UNIXTIME(oh.start_time))) as last_project_date,
|
|
-- Productivity metrics
|
|
SUM(oh.duration_hours) * 450 as total_labor_value,
|
|
DATEDIFF(MAX(FROM_UNIXTIME(oh.start_time)), MIN(FROM_UNIXTIME(oh.start_time))) + 1 as active_days,
|
|
COUNT(DISTINCT DATE(FROM_UNIXTIME(oh.start_time))) as working_days
|
|
FROM ordrestyring_hours oh
|
|
LEFT JOIN ordrestyring_users ou ON oh.employee_id = ou.id
|
|
WHERE oh.duration_hours > 0
|
|
GROUP BY oh.employee_id, ou.full_name
|
|
HAVING total_hours > 10
|
|
ORDER BY total_hours DESC
|
|
`);
|
|
|
|
return performance.map(emp => ({
|
|
...emp,
|
|
avg_hours_per_working_day: emp.working_days > 0 ?
|
|
Math.round((emp.total_hours / emp.working_days) * 10) / 10 : 0,
|
|
utilization_rate: emp.active_days > 0 ?
|
|
Math.round((emp.working_days / emp.active_days) * 1000) / 10 : 0,
|
|
value_per_hour: Math.round((emp.total_labor_value / emp.total_hours) * 10) / 10
|
|
}));
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async getCustomerAnalytics() {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
const [customers] = await connection.execute(`
|
|
SELECT
|
|
oc.customer_number,
|
|
COUNT(DISTINCT oc.case_number) as total_projects,
|
|
SUM(COALESCE((SELECT SUM(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number), 0)) as total_hours,
|
|
SUM(COALESCE((SELECT SUM(total_cost) FROM ordrestyring_case_materials WHERE case_number = oc.case_number), 0)) as total_material_costs,
|
|
SUM(COALESCE((SELECT SUM(total_sales) FROM ordrestyring_case_materials WHERE case_number = oc.case_number), 0)) as total_material_sales,
|
|
MIN(DATE(FROM_UNIXTIME(oc.start_date))) as first_project,
|
|
MAX(DATE(FROM_UNIXTIME(oc.end_date))) as last_project,
|
|
AVG(COALESCE((SELECT SUM(duration_hours) FROM ordrestyring_hours WHERE case_number = oc.case_number), 0)) as avg_project_hours,
|
|
AVG(COALESCE((SELECT SUM(total_sales) FROM ordrestyring_case_materials WHERE case_number = oc.case_number), 0)) as avg_project_value
|
|
FROM ordrestyring_cases oc
|
|
WHERE oc.customer_number IS NOT NULL
|
|
GROUP BY oc.customer_number
|
|
HAVING total_projects > 0
|
|
ORDER BY total_material_sales DESC
|
|
`);
|
|
|
|
return customers.map(customer => {
|
|
const laborValue = Number(customer.total_hours) * 450;
|
|
const totalValue = laborValue + Number(customer.total_material_sales);
|
|
const materialProfit = Number(customer.total_material_sales) - Number(customer.total_material_costs);
|
|
|
|
return {
|
|
...customer,
|
|
total_labor_value: Math.round(laborValue),
|
|
total_customer_value: Math.round(totalValue),
|
|
material_profit: Math.round(materialProfit),
|
|
avg_project_total_value: Math.round(totalValue / customer.total_projects),
|
|
customer_lifetime_months: customer.first_project && customer.last_project ?
|
|
Math.round((new Date(customer.last_project) - new Date(customer.first_project)) / (1000 * 60 * 60 * 24 * 30.44)) : 0
|
|
};
|
|
});
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async getMaterialInsights() {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
const [materials] = await connection.execute(`
|
|
SELECT
|
|
ocm.supplier,
|
|
COUNT(DISTINCT ocm.case_number) as projects_used,
|
|
COUNT(*) as total_line_items,
|
|
SUM(ocm.total_cost) as total_purchases,
|
|
SUM(ocm.total_sales) as total_sales,
|
|
AVG(CASE
|
|
WHEN ocm.cost_price > 0
|
|
THEN ((ocm.sales_price - ocm.cost_price) / ocm.cost_price) * 100
|
|
ELSE 0
|
|
END) as avg_margin_percent,
|
|
AVG(ocm.cost_price) as avg_unit_cost,
|
|
AVG(ocm.sales_price) as avg_unit_price,
|
|
SUM(ocm.total_sales - ocm.total_cost) as total_profit
|
|
FROM ordrestyring_case_materials ocm
|
|
WHERE ocm.cost_price > 0 AND ocm.sales_price > 0
|
|
GROUP BY ocm.supplier
|
|
HAVING total_purchases > 1000
|
|
ORDER BY total_purchases DESC
|
|
`);
|
|
|
|
return materials.map(material => ({
|
|
...material,
|
|
profit_margin_percent: Number(material.total_purchases) > 0 ?
|
|
Math.round((Number(material.total_profit) / Number(material.total_purchases)) * 1000) / 10 : 0,
|
|
avg_order_value: Math.round(Number(material.total_purchases) / material.projects_used),
|
|
items_per_project: Math.round(material.total_line_items / material.projects_used * 10) / 10
|
|
}));
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async getTrendAnalytics(months = 12) {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
const [trends] = await connection.execute(`
|
|
SELECT
|
|
YEAR(FROM_UNIXTIME(oh.start_time)) as year,
|
|
MONTH(FROM_UNIXTIME(oh.start_time)) as month,
|
|
DATE_FORMAT(FROM_UNIXTIME(oh.start_time), '%Y-%m') as month_year,
|
|
COUNT(DISTINCT oh.case_number) as active_projects,
|
|
COUNT(DISTINCT oh.employee_id) as active_employees,
|
|
SUM(oh.duration_hours) as total_hours,
|
|
AVG(oh.duration_hours) as avg_daily_hours,
|
|
COUNT(*) as total_entries
|
|
FROM ordrestyring_hours oh
|
|
WHERE oh.start_time >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL ? MONTH))
|
|
GROUP BY YEAR(FROM_UNIXTIME(oh.start_time)), MONTH(FROM_UNIXTIME(oh.start_time))
|
|
ORDER BY year DESC, month DESC
|
|
`, [months]);
|
|
|
|
return trends.map(trend => ({
|
|
...trend,
|
|
estimated_revenue: Math.round(Number(trend.total_hours) * 450),
|
|
productivity_score: Math.round((trend.total_hours / trend.active_employees / trend.active_projects) * 10) / 10,
|
|
utilization_rate: Math.round((trend.total_entries / (trend.active_employees * 22)) * 1000) / 10 // Assuming 22 working days
|
|
}));
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// QUOTE ACCURACY TRACKING
|
|
// ====================
|
|
|
|
async calculateQuoteAccuracyMetrics() {
|
|
const connection = await this.getConnection();
|
|
|
|
try {
|
|
// Get projects with enough data for accuracy analysis
|
|
const projects = await this.generateProjectAnalytics();
|
|
const FixedHistoricalQuoteCalculator = require('./historical-quote-calculator.js');
|
|
const calculator = new FixedHistoricalQuoteCalculator();
|
|
|
|
const accuracyResults = [];
|
|
|
|
for (const project of projects.slice(0, 20)) { // Analyze top 20 projects
|
|
if (project.actual_hours > 50 && project.material_sales > 10000) {
|
|
try {
|
|
const quote = await calculator.generateIntelligentQuote(
|
|
project.customer_number,
|
|
project.description,
|
|
3, // medium complexity
|
|
project.actual_hours > 1000 ? 'large' : 'medium'
|
|
);
|
|
|
|
if (quote.success) {
|
|
const hourAccuracy = (quote.quote.estimatedHours / project.actual_hours) * 100;
|
|
const valueAccuracy = (quote.quote.finalQuote / project.total_project_value) * 100;
|
|
|
|
accuracyResults.push({
|
|
case_number: project.case_number,
|
|
description: project.description,
|
|
actual_hours: project.actual_hours,
|
|
estimated_hours: quote.quote.estimatedHours,
|
|
actual_value: project.total_project_value,
|
|
estimated_value: quote.quote.finalQuote,
|
|
hour_accuracy: Math.round(hourAccuracy * 10) / 10,
|
|
value_accuracy: Math.round(valueAccuracy * 10) / 10,
|
|
confidence: quote.confidence
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.log(`Skipped analysis for case ${project.case_number}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await calculator.close();
|
|
|
|
// Calculate overall accuracy stats
|
|
if (accuracyResults.length > 0) {
|
|
const avgHourAccuracy = accuracyResults.reduce((sum, r) => sum + r.hour_accuracy, 0) / accuracyResults.length;
|
|
const avgValueAccuracy = accuracyResults.reduce((sum, r) => sum + r.value_accuracy, 0) / accuracyResults.length;
|
|
const avgConfidence = accuracyResults.reduce((sum, r) => sum + r.confidence, 0) / accuracyResults.length;
|
|
|
|
return {
|
|
total_analyzed: accuracyResults.length,
|
|
avg_hour_accuracy: Math.round(avgHourAccuracy * 10) / 10,
|
|
avg_value_accuracy: Math.round(avgValueAccuracy * 10) / 10,
|
|
avg_confidence: Math.round(avgConfidence * 10) / 10,
|
|
detailed_results: accuracyResults
|
|
};
|
|
}
|
|
|
|
return { total_analyzed: 0, message: "Insufficient data for accuracy analysis" };
|
|
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
// ====================
|
|
// COMPREHENSIVE ANALYTICS REPORT
|
|
// ====================
|
|
|
|
async generateFullAnalyticsReport() {
|
|
console.log('🔄 Generating comprehensive analytics report...');
|
|
|
|
const [
|
|
overallKPIs,
|
|
employeeMetrics,
|
|
customerAnalytics,
|
|
materialInsights,
|
|
trendData,
|
|
accuracyMetrics
|
|
] = await Promise.all([
|
|
this.getOverallKPIs(),
|
|
this.getEmployeePerformanceMetrics(),
|
|
this.getCustomerAnalytics(),
|
|
this.getMaterialInsights(),
|
|
this.getTrendAnalytics(12),
|
|
this.calculateQuoteAccuracyMetrics()
|
|
]);
|
|
|
|
return {
|
|
generated_at: new Date().toISOString(),
|
|
overview: overallKPIs,
|
|
employees: employeeMetrics,
|
|
customers: customerAnalytics.slice(0, 20), // Top 20 customers
|
|
suppliers: materialInsights,
|
|
trends: trendData,
|
|
quote_accuracy: accuracyMetrics,
|
|
summary: {
|
|
total_business_value: overallKPIs.total_project_value,
|
|
active_projects: overallKPIs.total_projects,
|
|
team_size: overallKPIs.active_employees,
|
|
avg_profit_margin: overallKPIs.overall_profit_margin,
|
|
quote_accuracy_score: accuracyMetrics.avg_value_accuracy || 0
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = CaseAnalyticsService; |