Files
tilbudgivern/backend/src/services/enhancedOrderDataService.js
T

488 lines
19 KiB
JavaScript

const mysql = require('mysql2/promise');
const axios = require('axios');
class EnhancedOrderDataService {
constructor(databaseService) {
this.db = databaseService;
this.apiBase = 'https://v2.api.ordrestyring.dk';
this.apiToken = process.env.ORDRESTYRING_TOKEN;
}
/**
* Intelligent material search: Database first, API fallback
*/
async findMaterials(searchCriteria) {
const results = {
source: 'hybrid',
database_results: [],
api_results: [],
total_matches: 0,
search_strategy: []
};
try {
// 1. Search local database first
console.log('🔍 Searching local database for materials...');
results.search_strategy.push('database_search');
const dbResults = await this.searchDatabaseMaterials(searchCriteria);
results.database_results = dbResults;
results.total_matches += dbResults.length;
console.log(`📊 Found ${dbResults.length} matches in local database`);
// 2. If we have good matches, use them
if (dbResults.length >= 3) {
results.source = 'database_primary';
return results;
}
// 3. If limited matches, supplement with API data
if (this.apiToken && dbResults.length < 5) {
console.log('🌐 Supplementing with live API data...');
results.search_strategy.push('api_supplement');
const apiResults = await this.searchAPIMaterials(searchCriteria);
results.api_results = apiResults;
results.total_matches += apiResults.length;
results.source = 'hybrid';
}
return results;
} catch (error) {
console.error('❌ Error in material search:', error);
throw error;
}
}
/**
* Search local database for materials
*/
async searchDatabaseMaterials(criteria) {
const { keywords, category, priceRange, supplier } = criteria;
try {
// Create connection to ordrestyring database
const mysql = require('mysql2/promise');
const ordrestyringDb = mysql.createPool({
host: 'localhost',
user: 'ordrestyring_user',
password: 'secure_password123',
database: 'ordrestyring_local',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0
});
let query = `
SELECT
cm.product_text,
COUNT(*) as usage_frequency,
ROUND(AVG(CAST(cm.sales_price AS DECIMAL(10,2))/100), 2) as price_dkk,
AVG(cm.quantity) as avg_quantity,
COUNT(DISTINCT cm.case_number) as project_count
FROM case_materials cm
WHERE cm.product_text IS NOT NULL
AND cm.product_text != ''
AND cm.sales_price > 0
`;
const params = [];
// Add keyword search
if (keywords && keywords.length > 0) {
const keywordConditions = keywords.map(() => 'cm.product_text LIKE ?').join(' OR ');
query += ` AND (${keywordConditions})`;
keywords.forEach(keyword => params.push(`%${keyword}%`));
}
// Add supplier filter
if (supplier) {
query += ` AND cm.supplier LIKE ?`;
params.push(`%${supplier}%`);
}
// Add price range
if (priceRange) {
if (priceRange.min) {
query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 >= ?`;
params.push(priceRange.min);
}
if (priceRange.max) {
query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 <= ?`;
params.push(priceRange.max);
}
}
query += `
GROUP BY cm.product_text
ORDER BY usage_frequency DESC, price_dkk ASC
LIMIT 20
`;
const [results] = await ordrestyringDb.execute(query, params);
// Close the connection
await ordrestyringDb.end();
// Group similar materials and calculate statistics
return this.processDatabaseResults(results);
} catch (error) {
console.error('❌ Error searching ordrestyring database:', error);
// Return empty results if database search fails
return [];
}
}
/**
* Search API for materials (fallback)
*/
async searchAPIMaterials(criteria) {
if (!this.apiToken) return [];
try {
const config = {
method: 'GET',
url: `${this.apiBase}/case-materials`,
auth: {
username: this.apiToken,
password: 'x'
},
params: {
pagesize: 50
}
};
const response = await axios(config);
const materials = Object.values(response.data || {});
// Filter API results based on criteria
return this.filterAPIResults(materials, criteria);
} catch (error) {
console.error('❌ API search failed:', error);
return [];
}
}
/**
* Process database results to add statistics
*/
processDatabaseResults(results) {
const processed = results.map(item => ({
...item,
source: 'database',
confidence: this.calculateConfidence(item),
price_stats: this.calculatePriceStats(item)
}));
return processed;
}
/**
* Filter API results based on search criteria
*/
filterAPIResults(materials, criteria) {
const { keywords } = criteria;
return materials
.filter(material => {
if (!material.product_text) return false;
if (keywords && keywords.length > 0) {
return keywords.some(keyword =>
material.product_text.toLowerCase().includes(keyword.toLowerCase())
);
}
return true;
})
.slice(0, 10)
.map(material => ({
...material,
source: 'api',
price_dkk: parseFloat(material.sales_price || 0) / 100,
confidence: 0.7 // Lower confidence for API data
}));
}
/**
* Calculate confidence score for materials
*/
calculateConfidence(item) {
let confidence = 0.5;
// Higher confidence for frequently used materials
if (item.usage_frequency > 10) confidence += 0.3;
else if (item.usage_frequency > 5) confidence += 0.2;
else if (item.usage_frequency > 2) confidence += 0.1;
// Higher confidence for recent data
if (item.case_number && parseInt(item.case_number) > 1500) confidence += 0.1;
// Higher confidence for known suppliers
if (item.supplier && item.supplier.includes('Bygma')) confidence += 0.1;
return Math.min(confidence, 1.0);
}
/**
* Calculate price statistics for a material
*/
calculatePriceStats(item) {
return {
current_price: item.price_dkk,
cost_margin: item.cost_dkk ? ((item.price_dkk - item.cost_dkk) / item.cost_dkk * 100).toFixed(1) : null,
usage_frequency: item.usage_frequency
};
}
/**
* Find similar projects based on description analysis
*/
async findSimilarProjects(description, projectType = null) {
try {
const keywords = this.extractKeywords(description);
console.log(`🔍 Finding similar projects for keywords: ${keywords.join(', ')}`);
// Build dynamic query to find similar projects
const whereConditions = [];
const params = [];
// Add keyword matching
keywords.forEach(keyword => {
whereConditions.push('c.description LIKE ?');
params.push(`%${keyword}%`);
});
// Simplified query for better performance
let query = `
SELECT
c.case_number,
c.description,
c.creation_date,
COUNT(cm.id) as material_count,
COALESCE(SUM(cm.sales_price), 0) as total_material_cost
FROM cases c
LEFT JOIN case_materials cm ON c.case_number = cm.case_number AND cm.sales_price > 0
WHERE ${whereConditions.join(' OR ')}
AND c.description IS NOT NULL
AND c.description != ''
GROUP BY c.case_number, c.description, c.creation_date
HAVING COUNT(cm.id) > 0 AND COALESCE(SUM(cm.sales_price), 0) > 1000
ORDER BY COALESCE(SUM(cm.sales_price), 0) DESC
LIMIT 10
`;
const results = await this.db.query(query, params);
// Calculate estimated totals and clean data
const projects = results.map(row => ({
case_number: row.case_number,
description: row.description || 'Ingen beskrivelse',
total_material_cost: Math.max(0, parseFloat(row.total_material_cost) || 0),
total_hours: 40, // Default estimate for labor
avg_hourly_rate: 800, // Standard hourly rate
material_count: parseInt(row.material_count) || 0,
estimated_total: 0, // Will be calculated below
creation_date: row.creation_date
}));
// Calculate estimated totals (materials + estimated labor)
projects.forEach(project => {
// Simple estimation: 40 hours labor for most projects
const laborCost = project.total_hours * project.avg_hourly_rate;
project.estimated_total = project.total_material_cost + laborCost;
});
console.log(`✅ Found ${projects.length} similar projects`);
return projects;
} catch (error) {
console.error('❌ Error finding similar projects:', error);
return [];
}
}
/**
* Extract keywords from project description
*/
extractKeywords(description) {
const commonKeywords = [
'køkken', 'bad', 'badeværelse', 'toilet', 'loft', 'gulv', 'væg', 'dør', 'vindue',
'maling', 'fliser', 'gips', 'isolering', 'el', 'vvs', 'tømrer', 'murer',
'renovering', 'ombygning', 'tilbygning', 'reparation', 'montering', 'terrasse',
'tag', 'facade', 'kælder', 'badekar', 'bruser', 'skabe', 'bordplade', 'granit',
'teak', 'træ', 'belysning', 'gelænder', 'hvidevarer', 'montage'
];
const words = description.toLowerCase().split(/\s+/);
const foundKeywords = words.filter(word => {
// Exact match with common keywords
if (commonKeywords.includes(word)) return true;
// Partial match for longer words
if (word.length > 4) {
return commonKeywords.some(keyword =>
word.includes(keyword) || keyword.includes(word)
);
}
return false;
});
// Also include longer words that might be important
const importantWords = words.filter(word => word.length > 5);
return [...new Set([...foundKeywords, ...importantWords])].slice(0, 8);
}
/**
* Calculate project similarity confidence
*/
calculateProjectConfidence(project, searchKeywords) {
let confidence = 0.3;
const projectWords = project.description.toLowerCase().split(/\s+/);
const matchingKeywords = searchKeywords.filter(keyword =>
projectWords.some(word => word.includes(keyword))
);
confidence += (matchingKeywords.length / searchKeywords.length) * 0.4;
// Recent projects get higher confidence
if (project.case_number && parseInt(project.case_number) > 1500) confidence += 0.2;
// Projects with more materials get higher confidence
if (project.material_count > 20) confidence += 0.1;
return Math.min(confidence, 1.0);
}
/**
* Generate comprehensive estimate
*/
async generateEstimate(projectDescription, requirements = {}) {
console.log('🎯 Generating comprehensive estimate...');
const estimate = {
project_description: projectDescription,
timestamp: new Date().toISOString(),
data_sources: [],
materials: {
database_matches: [],
api_supplements: [],
estimated_cost: 0
},
labor: {
estimated_hours: 0,
hourly_rate: 0,
total_cost: 0
},
similar_projects: [],
total_estimate: 0,
confidence_score: 0
};
try {
// 1. Find similar projects
const similarProjects = await this.findSimilarProjects(projectDescription);
estimate.similar_projects = similarProjects;
estimate.data_sources.push({
type: 'historical_projects',
count: similarProjects.length,
confidence: similarProjects.length > 0 ? Math.min(90, 30 + (similarProjects.length * 10)) : 30
});
// 2. Search for relevant materials
const materialCriteria = {
keywords: this.extractKeywords(projectDescription),
priceRange: requirements.budget ? { max: requirements.budget * 0.6 } : null
};
const materialResults = await this.findMaterials(materialCriteria);
estimate.materials.database_matches = materialResults.database_results;
estimate.materials.api_supplements = materialResults.api_results;
estimate.data_sources.push({
type: materialResults.source,
count: materialResults.total_matches,
confidence: materialResults.database_results.length > 0 ?
Math.min(95, 50 + (materialResults.database_results.length * 2)) : 30
});
// 3. Calculate estimates based on similar projects
if (similarProjects.length > 0) {
const validProjects = similarProjects.filter(p =>
p.total_material_cost >= 0 &&
p.total_hours >= 0 &&
p.total_hours <= 1000 && // Reasonable upper limit
p.avg_hourly_rate >= 100 &&
p.avg_hourly_rate <= 3000
);
if (validProjects.length > 0) {
const avgMaterialCost = validProjects.reduce((sum, p) => sum + (p.total_material_cost || 0), 0) / validProjects.length;
const avgHours = validProjects.reduce((sum, p) => sum + (p.total_hours || 0), 0) / validProjects.length;
const avgHourlyRate = validProjects.reduce((sum, p) => sum + (p.avg_hourly_rate || 800), 0) / validProjects.length;
estimate.materials.estimated_cost = Math.round(Math.max(0, avgMaterialCost));
estimate.labor.estimated_hours = Math.round(Math.max(0, Math.min(500, avgHours))); // Cap at 500 hours
estimate.labor.hourly_rate = Math.round(Math.max(400, Math.min(2000, avgHourlyRate))); // 400-2000 kr/hour
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
} else {
// Fallback estimates if no valid projects
estimate.materials.estimated_cost = Math.round((options.budget || 100000) * 0.6); // 60% materials
estimate.labor.estimated_hours = Math.round((options.budget || 100000) / 800 * 0.4 / 800); // 40% labor
estimate.labor.hourly_rate = 800;
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
}
} else {
// No similar projects found - use budget-based estimates
const budgetBased = requirements.budget || 100000;
estimate.materials.estimated_cost = Math.round(budgetBased * 0.65);
estimate.labor.estimated_hours = Math.round(budgetBased * 0.35 / 800);
estimate.labor.hourly_rate = 800;
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
}
// 4. Calculate total and confidence
estimate.total_estimate = Math.round(estimate.materials.estimated_cost + estimate.labor.total_cost);
estimate.confidence_score = this.calculateOverallConfidence(estimate);
console.log(`✅ Estimate generated: ${estimate.total_estimate} kr (confidence: ${(estimate.confidence_score * 100).toFixed(1)}%)`);
return estimate;
} catch (error) {
console.error('❌ Error generating estimate:', error);
throw error;
}
}
/**
* Calculate overall confidence for the estimate
*/
calculateOverallConfidence(estimate) {
let confidence = 0.2;
// More similar projects = higher confidence
if (estimate.similar_projects.length > 3) confidence += 0.3;
else if (estimate.similar_projects.length > 1) confidence += 0.2;
// More material matches = higher confidence
if (estimate.materials.database_matches.length > 5) confidence += 0.2;
else if (estimate.materials.database_matches.length > 2) confidence += 0.1;
// Recent data = higher confidence
const recentProjects = estimate.similar_projects.filter(p => parseInt(p.case_number) > 1500);
if (recentProjects.length > 0) confidence += 0.2;
// Multiple data sources = higher confidence
if (estimate.data_sources.length > 1) confidence += 0.1;
return Math.min(confidence, 1.0);
}
}
module.exports = EnhancedOrderDataService;