Files
tilbudgivern/case-analytics-service.js
alexpolo1 1d79f4710d feat: Implement comprehensive analytics population and data synchronization
- 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.
2025-10-14 13:06:02 +00:00

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;