488 lines
19 KiB
JavaScript
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;
|