Files
tilbudgivern/unified-server.js
T
alex 1615ec92c1 Add demo system and various analysis scripts
- Created demo_system.sh for interactive demo scenarios including TAG and B7 analysis, monthly economics, and complete system demo.
- Implemented legacy_tag_opgaver.sh to fetch and analyze real API data for TAG tasks, generating detailed CSV reports.
- Developed oekonomi_analyser.sh for economic analysis with options to fetch new data and specify the year for analysis.
- Introduced tag_analyser.sh for analyzing TAG tasks with options for fetching data and specifying task types.
2025-09-19 10:59:48 +02:00

5252 lines
157 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const path = require('path');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 4030;
// Load environment variables
require('dotenv').config();
// Import logger
const logger = require('./backend/src/utils/logger');
// Middleware
app.use(cors({
origin: '*', // Allow all origins since we're serving frontend and backend from same port
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
}));
// Additional CORS headers
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Security headers (less restrictive for development)
app.use((req, res, next) => {
res.header('Referrer-Policy', 'no-referrer-when-downgrade');
res.header('X-Content-Type-Options', 'nosniff');
next();
});
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Configure multer for file uploads
const multer = require('multer');
const upload = multer({
dest: 'backend/uploads/',
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
fileFilter: (req, file, cb) => {
// Accept PDF files only
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'), false);
}
}
});
// Configure multer for CSV file uploads
const csvUpload = multer({
dest: 'backend/uploads/csv/',
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, cb) => {
// Accept CSV and Excel files
if (file.mimetype === 'text/csv' ||
file.mimetype === 'application/vnd.ms-excel' ||
file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.originalname.toLowerCase().endsWith('.csv')) {
cb(null, true);
} else {
cb(new Error('Only CSV and Excel files are allowed'), false);
}
}
});
// Initialize backend services
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, ordrestyringService, realDataProjectSuggestionService;
const initializeBackend = async () => {
try {
// Import backend services (both are singletons)
databaseService = require('./backend/src/services/databaseService');
openaiService = require('./backend/src/services/openaiService');
webPriceService = require('./backend/src/services/webPriceService');
// Import new services
const DynamicImportService = require('./backend/src/services/dynamicImportService');
const OrderStatusService = require('./backend/src/services/orderStatusService');
const QuoteTemplateService = require('./backend/src/services/quoteTemplateService');
const PdfGenerationService = require('./backend/src/services/pdfGenerationService');
const OrdrestyringService = require('./backend/src/services/ordrestyringService');
const RealDataProjectSuggestionService = require('./backend/src/services/realDataProjectSuggestionService');
dynamicImportService = new DynamicImportService(databaseService);
orderStatusService = new OrderStatusService();
ordrestyringService = new OrdrestyringService();
orderStatusService = new OrderStatusService();
quoteTemplateService = new QuoteTemplateService();
pdfGenerationService = new PdfGenerationService();
realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService);
// Initialize database
await databaseService.initialize();
logger.info('Database initialized successfully');
return true;
} catch (error) {
logger.error('Failed to initialize backend:', error);
return false;
}
};
// API Routes - simplified versions of main endpoints
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'tilbudgivern-unified'
});
});
// OpenAI Stats endpoint
app.get('/api/quotes/openai/stats', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized'
});
}
const stats = await openaiService.getTokenUsageStats();
res.json({
success: true,
tokenUsage: stats,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error getting OpenAI stats:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente token statistik',
timestamp: new Date().toISOString()
});
}
});
// OpenAI Stats endpoint - update button functionality
app.post('/api/quotes/openai/stats/update', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized'
});
}
// Force refresh actual usage data from OpenAI API
const actualUsage = await openaiService.fetchActualUsageFromAPI();
// Get updated stats
const stats = await openaiService.getTokenUsageStats();
res.json({
success: true,
message: 'OpenAI budget data updated',
tokenUsage: stats,
actualUsage: actualUsage,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error updating OpenAI stats:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke opdatere budget data',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get completed quotes endpoint with enhanced database view
app.get('/api/quotes/completed', async (req, res) => {
try {
const { limit = 100, offset = 0, filter = 'all' } = req.query;
let query = `
SELECT
id,
quote_text,
quote_format,
ai_tokens_used,
ai_cost,
ai_model,
created_at,
project_name,
customer_name,
customer_email,
customer_phone,
customer_address,
project_description,
project_type,
project_icon,
total_incl_vat,
total_labor_cost,
total_material_cost,
subtotal,
vat_amount,
total_area,
roof_type,
roof_icon,
roof_pitch,
total_work_hours,
carpenter_count,
hourly_rate,
material_count,
material_categories
FROM v_enhanced_quotes
`;
const params = [];
// Add filter conditions
if (filter === 'static') {
query += ' WHERE ai_model = ?';
params.push('static');
} else if (filter === 'ai') {
query += ' WHERE ai_model != ?';
params.push('static');
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), parseInt(offset));
const quotes = await databaseService.query(query, params);
// Convert string numbers to actual numbers for proper frontend handling
const processedQuotes = quotes?.map(quote => ({
...quote,
ai_tokens_used: parseInt(quote.ai_tokens_used) || 0,
ai_cost: parseFloat(quote.ai_cost) || 0,
total_incl_vat: parseFloat(quote.total_incl_vat) || 0,
total_labor_cost: parseFloat(quote.total_labor_cost) || 0,
total_material_cost: parseFloat(quote.total_material_cost) || 0,
subtotal: parseFloat(quote.subtotal) || 0,
vat_amount: parseFloat(quote.vat_amount) || 0,
total_area: parseFloat(quote.total_area) || 0,
roof_pitch: parseFloat(quote.roof_pitch) || 0,
total_work_hours: parseFloat(quote.total_work_hours) || 0,
carpenter_count: parseInt(quote.carpenter_count) || 0,
hourly_rate: parseFloat(quote.hourly_rate) || 0,
material_count: parseInt(quote.material_count) || 0,
material_categories: quote.material_categories ? quote.material_categories.split(',') : []
})) || [];
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM v_enhanced_quotes';
const countParams = [];
if (filter === 'static') {
countQuery += ' WHERE ai_model = ?';
countParams.push('static');
} else if (filter === 'ai') {
countQuery += ' WHERE ai_model != ?';
countParams.push('static');
}
const [{ total }] = await databaseService.query(countQuery, countParams);
res.json({
success: true,
quotes: processedQuotes,
pagination: {
total: total || 0,
limit: parseInt(limit),
offset: parseInt(offset),
hasMore: (parseInt(offset) + parseInt(limit)) < (total || 0)
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching completed quotes:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente tilbud',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Delete quote endpoint
app.delete('/api/quotes/:quoteId', async (req, res) => {
try {
const { quoteId } = req.params;
// Validate quote ID
if (!quoteId || isNaN(parseInt(quoteId))) {
return res.status(400).json({
success: false,
error: 'Invalid quote ID'
});
}
// Check if quote exists
const existingQuote = await databaseService.query(
'SELECT gq.id, cp.project_name FROM generated_quotes gq LEFT JOIN customer_projects cp ON gq.project_id = cp.id WHERE gq.id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Delete the quote
await databaseService.query('DELETE FROM generated_quotes WHERE id = ?', [parseInt(quoteId)]);
logger.info(`Quote ${quoteId} deleted successfully`);
res.json({
success: true,
message: 'Tilbud slettet succesfuldt',
deletedQuote: existingQuote[0],
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error deleting quote:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke slette tilbud',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Quote generation endpoint
app.post('/api/quotes/generate', async (req, res) => {
try {
if (!openaiService || !databaseService) {
return res.status(503).json({
success: false,
error: 'Services not initialized'
});
}
const { description, area, projectType, additionalInfo, customerEmail } = req.body;
// Basic validation
if (!description || description.length < 10) {
return res.status(400).json({
success: false,
error: 'Beskrivelse skal være mindst 10 tegn'
});
}
logger.info('Generating quote', { description, area, projectType });
// Check if services are initialized
// Generate quote using OpenAI
const result = await openaiService.generateQuote({
description,
area: parseInt(area) || null,
projectType,
additionalInfo,
region: 'DK'
});
// Save quote to database
const savedQuote = await databaseService.saveQuote({
customerEmail,
description,
area,
projectType,
quote: result.quote,
metadata: result.metadata
});
res.json({
success: true,
quoteId: savedQuote.id,
quote: result.quote,
metadata: result.metadata
});
} catch (error) {
logger.error('Error generating quote:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved generering af tilbud'
});
}
});
// Carpenter hours calculation endpoint
app.post('/api/quotes/carpenter-hours', async (req, res) => {
try {
if (!openaiService) {
return res.status(500).json({
success: false,
error: 'OpenAI service ikke tilgængelig'
});
}
const { project_description, project_area, project_type } = req.body;
// Validate input
if (!project_description || !project_type) {
return res.status(400).json({
success: false,
error: 'Projekt beskrivelse og type er påkrævet'
});
}
// Calculate carpenter hours using OpenAI
const calculation = await openaiService.calculateCarpenterHours(
project_description,
project_area,
project_type
);
res.json({
success: true,
calculation: calculation
});
} catch (error) {
logger.error('Error calculating carpenter hours:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved beregning af tømrer timer'
});
}
});
// Save carpenter calculation to database
app.post('/api/quotes/save-carpenter-calculation', async (req, res) => {
try {
if (!databaseService) {
return res.status(500).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
const { calculation } = req.body;
if (!calculation) {
return res.status(400).json({
success: false,
error: 'Beregnings data er påkrævet'
});
}
// Save calculation to database
const result = await databaseService.saveCarpenterCalculation(calculation);
res.json({
success: true,
saved_calculation: {
id: result.id,
created_at: result.created_at
}
});
} catch (error) {
logger.error('Error saving carpenter calculation:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved gemning af beregning'
});
}
});
// Auth login endpoint
app.post('/api/auth/login', (req, res) => {
try {
const { username, password } = req.body;
// Simple hardcoded credentials - same as backend
const VALID_CREDENTIALS = {
username: process.env.AUTH_USERNAME || 'toemrer',
password: process.env.AUTH_PASSWORD || 'REDACTED_AUTH'
};
// Basic validation
if (!username || !password) {
return res.status(400).json({
success: false,
error: 'Ugyldig anmodning'
});
}
// Check credentials
if (username === VALID_CREDENTIALS.username && password === VALID_CREDENTIALS.password) {
logger.info('Successful login attempt:', { username, ip: req.ip });
res.json({
success: true,
message: 'Login successful'
});
} else {
logger.warn('Failed login attempt:', { username, ip: req.ip });
res.status(401).json({
success: false,
error: 'Forkert brugernavn eller adgangskode'
});
}
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({
success: false,
error: 'Der opstod en fejl'
});
}
});
// Legacy auth endpoint
app.post('/api/auth', (req, res) => {
res.json({
success: true,
message: 'Basic auth endpoint',
timestamp: new Date().toISOString()
});
});
// Web price search endpoints
app.post('/api/web-prices/search', async (req, res) => {
try {
const { materialType, saveToDatabase = false, specifications = {} } = req.body;
if (!materialType) {
return res.status(400).json({
success: false,
error: 'MaterialType er påkrævet'
});
}
logger.info(`Enhanced price search for: ${materialType}`);
// Use enhanced search with fallback to estimation
const results = await webPriceService.searchMaterialPricesWithFallback(materialType, specifications);
// Optionally save best results to database
if (saveToDatabase && results.results.length > 0) {
try {
const bestResult = results.results[0];
await databaseService.query(
'INSERT INTO ocr_materials (name, material_category, unit_price, unit, supplier_name, confidence, quantity, total_price, description, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[bestResult.name, materialType, bestResult.price, bestResult.unit, bestResult.source, bestResult.confidence, 1, bestResult.price, bestResult.description || '', bestResult.notes || '']
);
logger.info(`Saved price to database: ${bestResult.name} - ${bestResult.price} DKK`);
} catch (saveError) {
logger.warn('Failed to save price to database:', saveError.message);
}
}
res.json({
success: true,
materialType: materialType,
results: results.results,
summary: {
totalFound: results.totalResults,
averagePrice: results.averagePrice,
priceRange: results.priceRange
},
groupedBySource: results.groupedBySource,
timestamp: results.timestamp
});
} catch (error) {
logger.error('Error in web price search:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning efter webpriser',
details: error.message
});
}
});
app.post('/api/web-prices/carpenter-rates', async (req, res) => {
try {
const { workType = 'generel tømrerarbejde', saveToDatabase = false } = req.body;
logger.info(`Web search for carpenter rates: ${workType}`);
const results = await webPriceService.getTomrerRates(workType);
// Optionally save to database
if (saveToDatabase && results.rates.length > 0) {
try {
const rate = results.rates[0];
await databaseService.query(
'INSERT INTO project_quotes (project_type, notes, labor_rate, labor_cost, material_cost, overhead_cost, total_excl_vat, vat_amount, total_incl_vat, difficulty_multiplier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[workType, `Web-hentet tømrersats - ${rate.notes}`, rate.hourlyRate, 0, 0, 0, 0, 0, 0, 1.0]
);
logger.info(`Saved carpenter rate to database: ${workType} - ${rate.hourlyRate} DKK/time`);
} catch (saveError) {
logger.warn('Failed to save carpenter rate to database:', saveError.message);
}
}
res.json({
success: true,
workType: workType,
rates: results.rates,
averageRate: results.averageRate,
timestamp: results.timestamp
});
} catch (error) {
logger.error('Error in carpenter rates search:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning efter tømrersatser',
details: error.message
});
}
});
app.get('/api/web-prices/suggestions/:projectType', async (req, res) => {
try {
const { projectType } = req.params;
// Get material suggestions for project type
const suggestions = webPriceService.getSearchTerms(projectType);
res.json({
success: true,
projectType: projectType,
suggestions: suggestions
});
} catch (err) {
res.status(500).json({
success: false,
error: 'Fejl ved hentning af forslag'
});
}
});
// Price import endpoints
app.post('/api/pricing/import', csvUpload.single('file'), async (req, res) => {
let uploadedFilePath = null;
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Ingen fil uploadet'
});
}
uploadedFilePath = req.file.path;
// Parse CSV file
const parseResult = await importService.importPricesFromCSV(uploadedFilePath);
if (parseResult.results.length === 0) {
return res.status(400).json({
success: false,
error: 'Ingen gyldige priser fundet i filen',
errors: parseResult.errors
});
}
// Insert to database
const insertResult = await importService.bulkInsertPrices(parseResult.results);
res.json({
success: true,
message: `${insertResult.inserted.length} priser importeret succesfuldt`,
imported: insertResult.inserted,
failed: insertResult.failed,
errors: parseResult.errors
});
} catch (err) {
logger.error('Price import error:', err);
res.status(500).json({
success: false,
error: 'Fejl ved import af priser',
details: err.message
});
} finally {
// Always clean up uploaded file
if (uploadedFilePath) {
try {
require('fs').unlinkSync(uploadedFilePath);
logger.debug('Uploaded file cleaned up:', uploadedFilePath);
} catch (cleanupError) {
logger.warn('Failed to clean up uploaded file:', cleanupError.message);
}
}
}
});
app.get('/api/prices/template', (req, res) => {
try {
const PriceImportService = require('./backend/src/services/priceImportService').PriceImportService;
const importService = new PriceImportService();
const template = importService.generateCSVTemplate();
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="price_template.csv"');
res.send(template);
} catch (err) {
res.status(500).json({
success: false,
error: 'Fejl ved generering af skabelon'
});
}
});
// Dynamic CSV import endpoint
app.post('/api/pricing/dynamic-import', csvUpload.single('file'), async (req, res) => {
let uploadedFilePath = null;
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Ingen fil uploadet'
});
}
uploadedFilePath = req.file.path;
logger.info('Processing dynamic CSV import:', req.file.originalname);
// Use dynamic import service
const result = await dynamicImportService.analyzeAndImportCSV(uploadedFilePath);
res.json({
success: true,
message: 'CSV importeret succesfuldt med dynamisk struktur',
...result
});
} catch (error) {
logger.error('Error in dynamic CSV import:', error);
res.status(500).json({
success: false,
error: 'Fejl ved dynamisk CSV import',
details: error.message
});
} finally {
// Always clean up uploaded file
if (uploadedFilePath) {
try {
require('fs').unlinkSync(uploadedFilePath);
logger.debug('Uploaded file cleaned up:', uploadedFilePath);
} catch (cleanupError) {
logger.warn('Failed to clean up uploaded file:', cleanupError.message);
}
}
}
});
// Reset dynamic materials table (development only)
app.post('/api/pricing/reset-dynamic-table', async (req, res) => {
try {
logger.info('Resetting dynamic materials table');
// Drop the existing table
await databaseService.query('DROP TABLE IF EXISTS dynamic_materials');
res.json({
success: true,
message: 'Dynamic materials table reset successfully'
});
} catch (error) {
logger.error('Error resetting dynamic table:', error);
res.status(500).json({
success: false,
message: 'Fejl ved reset af tabel',
error: error.message
});
}
});
// ========================================
// BYGMA PRISBOG IMPORT ENDPOINTS
// ========================================
// Bygma prisbog upload and import endpoint
app.post('/api/pricing/bygma-prisbog', csvUpload.single('file'), async (req, res) => {
let uploadedFilePath = null;
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Ingen fil uploadet'
});
}
// Validate file type more strictly for Bygma prisbog
if (!req.file.originalname.toLowerCase().endsWith('.csv')) {
return res.status(400).json({
success: false,
error: 'Kun CSV filer er tilladt for Bygma prisbog'
});
}
uploadedFilePath = req.file.path;
const uploadedBy = req.body.uploadedBy || 'api_user';
logger.info('Processing Bygma prisbog import:', {
filename: req.file.originalname,
size: req.file.size,
uploadedBy
});
// Initialize Bygma import service
const BygmaPrisbogImportService = require('./backend/src/services/bygmaPrisbogImportService');
const importService = new BygmaPrisbogImportService();
// Read file and process
const fs = require('fs');
const fileBuffer = fs.readFileSync(uploadedFilePath);
const result = await importService.uploadAndProcessFile(
fileBuffer,
req.file.originalname,
uploadedBy
);
if (result.success) {
res.json({
success: true,
message: `Bygma prisbog importeret succesfuldt! ${result.stats.newProducts} nye produkter, ${result.stats.updatedPrices} priser opdateret.`,
batchId: result.batchId,
stats: result.stats,
warnings: result.warnings,
duration: result.duration
});
} else {
res.status(400).json({
success: false,
error: result.error,
stats: result.stats,
errors: result.errors
});
}
} catch (error) {
logger.error('Error in Bygma prisbog import:', error);
res.status(500).json({
success: false,
error: 'Fejl ved import af Bygma prisbog',
details: error.message
});
} finally {
// Always clean up uploaded file
if (uploadedFilePath) {
try {
require('fs').unlinkSync(uploadedFilePath);
logger.debug('Bygma prisbog file cleaned up:', uploadedFilePath);
} catch (cleanupError) {
logger.warn('Failed to clean up Bygma file:', cleanupError.message);
}
}
}
});
// Get Bygma import history
app.get('/api/pricing/bygma-imports', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const BygmaPrisbogImportService = require('./backend/src/services/bygmaPrisbogImportService');
const importService = new BygmaPrisbogImportService();
const history = await importService.getImportHistory(limit);
res.json({
success: true,
imports: history
});
} catch (error) {
logger.error('Error fetching Bygma import history:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af import historik'
});
}
});
// Get current Bygma prices
app.get('/api/pricing/bygma-prices', async (req, res) => {
try {
const { productCodes, limit = 100, search } = req.query;
const BygmaPrisbogImportService = require('./backend/src/services/bygmaPrisbogImportService');
const importService = new BygmaPrisbogImportService();
let codes = null;
if (productCodes) {
codes = productCodes.split(',').map(code => code.trim());
}
let prices = await importService.getCurrentPrices(codes);
// Apply search filter if provided
if (search) {
const searchLower = search.toLowerCase();
prices = prices.filter(price =>
price.product_name.toLowerCase().includes(searchLower) ||
price.product_code.toLowerCase().includes(searchLower)
);
}
// Limit results
if (limit) {
prices = prices.slice(0, parseInt(limit));
}
res.json({
success: true,
prices: prices,
count: prices.length
});
} catch (error) {
logger.error('Error fetching Bygma prices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af Bygma priser'
});
}
});
// Get Bygma price statistics
app.get('/api/pricing/bygma-stats', async (req, res) => {
try {
const stats = await databaseService.query(`
SELECT
COUNT(DISTINCT p.id) as total_products,
COUNT(DISTINCT p.product_group) as total_groups,
COUNT(ph.id) as total_price_records,
MAX(ph.import_date) as last_import_date,
AVG(ph.net_price) as avg_net_price,
MIN(ph.net_price) as min_net_price,
MAX(ph.net_price) as max_net_price
FROM bygma_products p
LEFT JOIN bygma_price_history ph ON p.id = ph.product_id
WHERE p.is_active = TRUE
`);
const recentImports = await databaseService.query(`
SELECT
DATE(started_at) as import_date,
COUNT(*) as import_count,
SUM(new_products) as new_products,
SUM(updated_prices) as updated_prices
FROM bygma_import_log
WHERE started_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(started_at)
ORDER BY import_date DESC
`);
res.json({
success: true,
stats: stats[0] || {},
recentImports: recentImports || []
});
} catch (error) {
logger.error('Error fetching Bygma statistics:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af statistik'
});
}
});
// Test direct Bygma import from file path (for development)
app.post('/api/pricing/bygma-test-import', async (req, res) => {
try {
const { filePath, uploadedBy = 'test_user' } = req.body;
if (!filePath) {
return res.status(400).json({
success: false,
error: 'FilePath er påkrævet'
});
}
const BygmaPrisbogImportService = require('./backend/src/services/bygmaPrisbogImportService');
const importService = new BygmaPrisbogImportService();
const result = await importService.importPrisbog(filePath, uploadedBy);
res.json(result);
} catch (error) {
logger.error('Error in Bygma test import:', error);
res.status(500).json({
success: false,
error: 'Fejl ved test import',
details: error.message
});
}
});
// Get dynamic materials with flexible querying
app.get('/api/materials/dynamic', async (req, res) => {
try {
const { category, supplier, search, limit = 50 } = req.query;
let whereClause = 'WHERE 1=1';
const params = [];
if (category) {
whereClause += ' AND category = ?';
params.push(category);
}
if (supplier) {
whereClause += ' AND supplier = ?';
params.push(supplier);
}
if (search) {
whereClause += ' AND (name LIKE ? OR category LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
const materials = await databaseService.query(
`SELECT * FROM dynamic_materials ${whereClause} ORDER BY created_at DESC LIMIT ?`,
[...params, parseInt(limit)]
);
res.json({
success: true,
materials: materials || [],
count: materials?.length || 0
});
} catch (error) {
logger.error('Error fetching dynamic materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialer'
});
}
});
// Order Status API Integration Endpoints
// Convert quote to order in ordrestatus.dk
app.post('/api/orders/create-from-quote/:quoteId', async (req, res) => {
try {
const { quoteId } = req.params;
const result = await orderStatusService.convertQuoteToOrder(quoteId);
res.json({
success: true,
message: 'Ordre oprettet succesfuldt i ordrestatus.dk',
...result
});
} catch (error) {
logger.error('Error creating order from quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved oprettelse af ordre',
details: error.message
});
}
});
// Update order status
app.patch('/api/orders/:orderId/status', async (req, res) => {
try {
const { orderId } = req.params;
const { status, notes } = req.body;
const result = await orderStatusService.updateOrderStatus(orderId, status, notes);
res.json({
success: true,
message: 'Ordrestatus opdateret succesfuldt',
...result
});
} catch (error) {
logger.error('Error updating order status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af ordrestatus',
details: error.message
});
}
});
// Get all orders
app.get('/api/orders', async (req, res) => {
try {
const filters = req.query;
const orders = await orderStatusService.getOrders(filters);
res.json({
success: true,
orders: orders
});
} catch (error) {
logger.error('Error fetching orders:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af ordrer',
details: error.message
});
}
});
// Add task to order
app.post('/api/orders/:orderId/tasks', async (req, res) => {
try {
const { orderId } = req.params;
const taskData = req.body;
const result = await orderStatusService.addTask(orderId, taskData);
res.json({
success: true,
message: 'Opgave tilføjet til ordre',
...result
});
} catch (error) {
logger.error('Error adding task to order:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af opgave',
details: error.message
});
}
});
// Update task status
app.patch('/api/orders/:orderId/tasks/:taskId/status', async (req, res) => {
try {
const { orderId, taskId } = req.params;
const { status, notes } = req.body;
const result = await orderStatusService.updateTaskStatus(orderId, taskId, status, notes);
res.json({
success: true,
message: 'Opgavestatus opdateret succesfuldt',
...result
});
} catch (error) {
logger.error('Error updating task status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af opgavestatus',
details: error.message
});
}
});
// Log time on task
app.post('/api/orders/:orderId/tasks/:taskId/time', async (req, res) => {
try {
const { orderId, taskId } = req.params;
const timeData = req.body;
const result = await orderStatusService.logTime(orderId, taskId, timeData);
res.json({
success: true,
message: 'Tid registreret succesfuldt',
...result
});
} catch (error) {
logger.error('Error logging time:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tidsregistrering',
details: error.message
});
}
});
// Get order status for all quotes
app.get('/api/orders/status-summary', async (req, res) => {
try {
const statusSummary = await orderStatusService.getOrderStatusForQuotes();
res.json({
success: true,
statusSummary: statusSummary
});
} catch (error) {
logger.error('Error getting order status summary:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af ordrestatus oversigt',
details: error.message
});
}
});
app.get('/api/prices/suggestions', async (req, res) => {
try {
const { material, category } = req.query;
if (!material || !category) {
return res.status(400).json({
success: false,
error: 'Material og kategori er påkrævet'
});
}
const PriceImportService = require('./backend/src/services/priceImportService').PriceImportService;
const importService = new PriceImportService(databaseService);
const suggestions = await importService.getSuggestedPrices(material, category);
res.json({
success: true,
material: material,
category: category,
suggestions: suggestions
});
} catch (err) {
res.status(500).json({
success: false,
error: 'Fejl ved hentning af prisforslag'
});
}
});
// Categories endpoints
app.get('/api/categories/list', async (req, res) => {
try {
const categories = await databaseService.getAllCategories();
res.json({
success: true,
materialCategories: categories.materialCategories,
projectTypes: categories.projectTypes
});
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({ success: false, message: 'Error fetching categories' });
}
});
// Get labor prices for category
app.get('/api/categories/labor/:category', async (req, res) => {
try {
const { category } = req.params;
const laborPrices = await databaseService.getLaborPricesByCategory(category);
res.json({
success: true,
laborPrices: laborPrices || []
});
} catch (error) {
console.error('Error fetching labor prices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af arbejdspriser'
});
}
});
// Get material prices for category
app.get('/api/categories/materials/:category', async (req, res) => {
try {
const { category } = req.params;
const materialPrices = await databaseService.getMaterialPricesByCategory(category);
res.json({
success: true,
materialPrices: materialPrices || []
});
} catch (error) {
console.error('Error fetching material prices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialepriser'
});
}
});
// Alternative route for material categories (singular form)
app.get('/api/categories/material/:category', async (req, res) => {
try {
const { category } = req.params;
const materialPrices = await databaseService.getMaterialPricesByCategory(category);
res.json({
success: true,
materialPrices: materialPrices || []
});
} catch (error) {
console.error('Error fetching material prices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialepriser'
});
}
});
// Add new labor price
app.post('/api/categories/labor', async (req, res) => {
try {
const {
projectType,
description,
hourlyRate,
area,
totalHours,
difficulty,
notes,
location
} = req.body;
if (!projectType || !description || !hourlyRate) {
return res.status(400).json({
success: false,
error: 'Projekttype, beskrivelse og timepris er påkrævet'
});
}
// Convert difficulty to multiplier
const difficultyMultiplier = difficulty === 'høj' ? 1.4 :
difficulty === 'medium' ? 1.2 : 1.0;
// Calculate labor cost
const calculatedLaborCost = (totalHours || 0) * parseFloat(hourlyRate);
// Save to database
const result = await databaseService.query(
`INSERT INTO project_quotes
(project_type, notes, labor_rate, project_area, labor_cost, difficulty_multiplier,
material_cost, overhead_cost, total_excl_vat, vat_amount, total_incl_vat, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
projectType,
description,
parseFloat(hourlyRate),
area ? parseFloat(area) : null,
calculatedLaborCost,
difficultyMultiplier,
0, // material_cost
0, // overhead_cost
calculatedLaborCost, // total_excl_vat
calculatedLaborCost * 0.25, // vat_amount (25%)
calculatedLaborCost * 1.25 // total_incl_vat
]
);
console.log('New labor price saved to database:', {
id: result.insertId,
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
area: area ? parseFloat(area) : null,
totalHours: totalHours ? parseFloat(totalHours) : null,
difficulty: difficulty || 'normal',
location: location || null,
notes: notes || null
});
res.json({
success: true,
message: 'Arbejdspris tilføjet succesfuldt',
laborEntry: {
id: result.insertId,
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
area: area ? parseFloat(area) : null,
totalHours: totalHours ? parseFloat(totalHours) : null,
difficulty: difficulty || 'normal',
location: location || null,
notes: notes || null,
created_at: new Date().toISOString()
}
});
} catch (error) {
console.error('Error adding labor price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af arbejdspris'
});
}
});
// Add new material price
app.post('/api/categories/materials', async (req, res) => {
try {
const {
name,
category,
subcategory,
price,
unit,
supplierName,
sku,
description,
notes
} = req.body;
if (!name || !category || !price) {
return res.status(400).json({
success: false,
error: 'Navn, kategori og pris er påkrævet'
});
}
// For now, just log the data instead of saving to database
console.log('New material price submission:', {
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplierName: supplierName || null,
sku: sku || null,
description: description || null,
notes: notes || null
});
// Create OCR document entry for the supplier
const docResult = await databaseService.query(
`INSERT INTO ocr_documents
(filename, supplier_name, document_date, created_at)
VALUES (?, ?, NOW(), NOW())`,
[`manual_entry_${Date.now()}.json`, supplierName || 'Manuel indtastning']
);
// Save material to database
const materialResult = await databaseService.query(
`INSERT INTO ocr_materials
(name, material_category, description, unit_price, unit, sku, confidence, ocr_document_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
name,
category,
subcategory || null,
parseFloat(price),
unit || 'stk',
sku || null,
1.0, // Full confidence for manual entries
docResult.insertId
]
);
console.log('New material price saved to database:', {
id: materialResult.insertId,
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplierName: supplierName || null,
sku: sku || null
});
res.json({
success: true,
message: 'Materialepris tilføjet succesfuldt',
materialEntry: {
id: materialResult.insertId,
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplier_name: supplierName || null,
sku: sku || null,
description: description || null,
notes: notes || null,
created_at: new Date().toISOString()
}
});
} catch (error) {
console.error('Error adding material price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af materialepris'
});
}
});
// Update labor price
// Create new labor price
// Delete labor price
app.delete('/api/categories/labor/:id', async (req, res) => {
try {
const { id } = req.params;
console.log('Delete labor price:', id);
// Delete from project_quotes table
const result = await databaseService.query(
'DELETE FROM project_quotes WHERE id = ?',
[parseInt(id)]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Arbejdspris ikke fundet'
});
}
console.log('Labor price deleted successfully:', id);
res.json({
success: true,
message: 'Arbejdspris slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting labor price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af arbejdspris'
});
}
});
// Update labor price
app.put('/api/categories/labor/:id', async (req, res) => {
try {
const { id } = req.params;
const {
projectType,
description,
hourlyRate,
area,
totalHours,
difficulty,
notes,
location
} = req.body;
if (!projectType || !description || !hourlyRate) {
return res.status(400).json({
success: false,
error: 'Projekttype, beskrivelse og timepris er påkrævet'
});
}
console.log('Update labor price:', id, {
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
area: area ? parseFloat(area) : null,
totalHours: totalHours ? parseFloat(totalHours) : null,
difficulty: difficulty || 'normal',
location: location || null,
notes: notes || null
});
// Convert difficulty to multiplier
const difficultyMultiplier = difficulty === 'høj' ? 1.4 :
difficulty === 'medium' ? 1.2 : 1.0;
// Calculate labor cost
const calculatedLaborCost = (totalHours || 0) * parseFloat(hourlyRate);
// Update the labor price in database
const result = await databaseService.query(
`UPDATE project_quotes
SET project_type = ?,
notes = ?,
labor_rate = ?,
project_area = ?,
labor_cost = ?,
difficulty_multiplier = ?
WHERE id = ?`,
[
projectType,
description,
parseFloat(hourlyRate),
area ? parseFloat(area) : null,
calculatedLaborCost,
difficultyMultiplier,
parseInt(id)
]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Arbejdspris ikke fundet'
});
}
console.log('Labor price updated successfully:', id);
res.json({
success: true,
message: 'Arbejdspris opdateret succesfuldt',
laborEntry: {
id: parseInt(id),
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
area: area ? parseFloat(area) : null,
totalHours: totalHours ? parseFloat(totalHours) : null,
difficulty: difficulty || 'normal',
location: location || null,
notes: notes || null,
updatedAt: new Date().toISOString()
}
});
} catch (error) {
console.error('Error updating labor price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af arbejdspris'
});
}
});
// Update material price
app.put('/api/categories/materials/:id', async (req, res) => {
try {
const { id } = req.params;
const {
name,
category,
subcategory,
price,
unit,
supplierName,
sku,
description,
notes
} = req.body;
if (!name || !category || !price) {
return res.status(400).json({
success: false,
error: 'Navn, kategori og pris er påkrævet'
});
}
// Update the material in database
const result = await databaseService.query(
`UPDATE ocr_materials
SET name = ?,
material_category = ?,
description = ?,
unit_price = ?,
unit = ?,
sku = ?
WHERE id = ?`,
[
name,
category,
subcategory || null,
parseFloat(price),
unit || 'stk',
sku || null,
parseInt(id)
]
);
// Update supplier information if available
if (supplierName) {
await databaseService.query(
`UPDATE ocr_documents od
JOIN ocr_materials om ON od.id = om.ocr_document_id
SET od.supplier_name = ?
WHERE om.id = ?`,
[supplierName, parseInt(id)]
);
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materialepris ikke fundet'
});
}
console.log('Material price updated successfully:', id, {
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplierName: supplierName || null,
sku: sku || null
});
res.json({
success: true,
message: 'Materialepris opdateret succesfuldt',
materialEntry: {
id: parseInt(id),
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplier_name: supplierName || null,
sku: sku || null,
description: description || null,
notes: notes || null,
updated_at: new Date().toISOString()
}
});
} catch (error) {
console.error('Error updating material price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materialepris'
});
}
});
// Delete material price
app.delete('/api/categories/materials/:id', async (req, res) => {
try {
const { id } = req.params;
console.log('Delete material price:', id);
// Delete from ocr_materials table
const result = await databaseService.query(
'DELETE FROM ocr_materials WHERE id = ?',
[parseInt(id)]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materialepris ikke fundet'
});
}
console.log('Material price deleted successfully:', id);
res.json({
success: true,
message: 'Materialepris slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting material price:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materialepris'
});
}
});
// Main categories endpoint with counts
// File upload endpoint
app.post('/api/uploads', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Ingen fil uploaded'
});
}
console.log('File uploaded:', req.file.filename);
res.json({
success: true,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
message: 'Fil uploaded succesfuldt'
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({
success: false,
error: 'Der opstod en fejl ved upload af fil'
});
}
});
// Pricing endpoints for "Tømrer priser" and "Materiale priser"
app.get('/api/pricing/health', (req, res) => {
res.json({
success: true,
status: 'Pricing service er kørende',
timestamp: new Date().toISOString()
});
});
// Get historical material prices
app.get('/api/pricing/history/:materialName', async (req, res) => {
try {
const { materialName } = req.params;
const { limit = 10 } = req.query;
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service not available'
});
}
const historicalPrices = await databaseService.getHistoricalMaterialPrices(
materialName,
parseInt(limit)
);
res.json({
success: true,
materialName,
historicalPrices,
count: historicalPrices.length,
message: `Fundet ${historicalPrices.length} historiske priser for ${materialName}`
});
} catch (error) {
console.error('Error getting historical prices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af historiske priser',
details: error.message
});
}
});
// Search materials
app.get('/api/pricing/search', async (req, res) => {
try {
const { q: searchTerm, type: projectType, limit = 20 } = req.query;
if (!searchTerm) {
return res.status(400).json({
success: false,
error: 'Søgeterm (q) er påkrævet'
});
}
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service not available'
});
}
const searchResults = await databaseService.searchOcrMaterials(
searchTerm,
projectType,
parseInt(limit)
);
res.json({
success: true,
searchTerm,
projectType: projectType || 'alle',
results: searchResults,
totalMatches: searchResults.length,
message: `Fundet ${searchResults.length} materialer for "${searchTerm}"`
});
} catch (error) {
console.error('Error searching materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning i materialer',
details: error.message
});
}
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'tilbudgivern-unified',
timestamp: new Date().toISOString()
});
});
// Serve static files from React build
const frontendBuildPath = path.join(__dirname, 'frontend', 'build');
console.log('📁 Serving frontend from:', frontendBuildPath);
app.use(express.static(frontendBuildPath));
// Customer Projects API Routes
app.use('/api/customer', require('./backend/src/routes/customerProjects'));
// Real Data Project Suggestions - Baseret på faktiske Ordrestyring data
app.post('/api/real-project-suggestions', async (req, res) => {
try {
const { keywords, customerType } = req.body;
if (!realDataProjectSuggestionService) {
return res.status(503).json({
success: false,
error: 'Real data suggestion service ikke tilgængelig'
});
}
const suggestions = await realDataProjectSuggestionService.getProjectSuggestions(
keywords || '',
customerType || 'standard'
);
res.json({
success: true,
data: suggestions
});
} catch (error) {
console.error('Real project suggestions error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt forslag: ' + error.message
});
}
});
// Hent faktiske projekt typer fra virkelige data
app.get('/api/real-project-types', async (req, res) => {
try {
if (!realDataProjectSuggestionService) {
return res.status(503).json({
success: false,
error: 'Real data suggestion service ikke tilgængelig'
});
}
const projectTypes = await realDataProjectSuggestionService.getRealProjectTypes();
res.json({
success: true,
data: projectTypes
});
} catch (error) {
console.error('Real project types error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt typer: ' + error.message
});
}
});
// ========================================
// MATERIALS API ENDPOINTS (KOMBINERET BYGMA + BUILT-IN)
// ========================================
// Cache for materialer - forbedrer performance
const materialsCache = {
data: null,
lastUpdate: null,
ttl: 5 * 60 * 1000, // 5 minutter cache
isValid() {
return this.data && this.lastUpdate && (Date.now() - this.lastUpdate < this.ttl);
},
set(data) {
this.data = data;
this.lastUpdate = Date.now();
},
clear() {
this.data = null;
this.lastUpdate = null;
}
};
// Helper function til category mapping
function getCategoryDisplayName(categoryCode, mapping) {
return mapping[categoryCode] || categoryCode;
}
// Global Bygma category mapping - bruges i cache og queries
const globalBygmaCategoryMapping = {
'5320': 'Arbejdstøj og sikkerhedsudstyr',
'5530': 'Håndværktøj og værktøj',
'5330': 'Måleværktøj og tilbehør',
'5160': 'Skruer og beslag',
'5170': 'Søm og fastgørelse',
'5610': 'Elektrisk værktøj og tilbehør',
'5550': 'Specialværktøj',
'5180': 'Lim og bindemidler',
'5510': 'Snittværktøj og klinger',
'4750': 'Stilladser og understøtning',
'4810': 'Byggematerialer og plader',
'3210': 'Befæstninger og metal',
'5340': 'Opmåling og afmærkning',
'5520': 'Slibe- og polérværktøj',
'5150': 'Rustfrit stål produkter',
'5540': 'Batterier og akku værktøj',
'5490': 'Rengøring og vedligeholdelse',
'5140': 'Maskiner og anlæg',
'5350': 'Fuge- og spartelmateriale',
'5430': 'Opbevaring og transport',
'5560': 'El-installationer og kabler',
'4760': 'Isolering og byggeplader',
'5310': 'Tømmerforbindelser',
'5050': 'VVS og rør',
'1000': 'Trælast og byggeplader',
'3610': 'Metalbefæstning',
'4840': 'Tagmaterialer',
'7030': 'Maling og overfladebehandling',
'4860': 'Vandtætning og tagpap',
'7020': 'Spartel og fugemasse',
'5570': 'Elektriske komponenter',
'4880': 'Facadematerialer',
'6150': 'Ventilation og luftbehandling',
'4780': 'Terrassematerialer',
'7010': 'Lak og træbeskyttelse',
'3510': 'Rustfrit stål systemer',
'7560': 'Haveredskaber og udendørs',
'6110': 'Varme og klimaanlæg',
'3410': 'Industrimetal',
'1005': 'Konstruktionstræ',
'6120': 'Ventilationskomponenter',
'6140': 'Varmepumper', '4890': 'Gulvmaterialer', '7320': 'Facade maling', '6310': 'VVS',
'3230': 'Stålprofiler', '4740': 'Døre/vinduer', '4820': 'Trælast', '3820': 'Ventiler',
'9121': 'Teknisk rådgivning', '6250': 'Ventilationsstyring', '1014': 'Konstruktionstræ special'
// Afkortet for at spare plads - resten vil falde tilbage til numeric koder
};
// Get all materials (both built-in and Bygma) - hovedendpoint for materiale siden
app.get('/api/pricing/materials', async (req, res) => {
try {
const category = req.query.category;
const search = req.query.search || '';
const limit = parseInt(req.query.limit) || 50;
const page = parseInt(req.query.page) || 1;
const offset = (page - 1) * limit;
const includeBygma = req.query.includeBygma !== 'false'; // default true
// Cache key baseret på query parametre (kun for simple queries)
const isSimpleQuery = !search && (!category || category === 'alle') && includeBygma;
const useCache = isSimpleQuery;
// Tjek cache først for simple queries
if (useCache && materialsCache.isValid()) {
console.log('📦 Cache hit for materials query');
const cachedData = materialsCache.data;
const startIdx = offset;
const endIdx = offset + limit;
const paginatedMaterials = cachedData.materials.slice(startIdx, endIdx);
return res.json({
success: true,
data: paginatedMaterials,
pagination: {
total: cachedData.totalCount,
limit: limit,
page: page,
offset: offset,
pages: Math.ceil(cachedData.totalCount / limit),
hasMore: endIdx < cachedData.totalCount
},
source_info: cachedData.source_info
});
}
let allMaterials = [];
let totalCount = 0;
// Hent Bygma materialer (primær kilde)
if (includeBygma) {
let bygmaQuery = `
SELECT
bp.id,
bp.vareNr as product_code,
bp.tekst as name,
bp.varegrp as category,
bp.enhed as unit,
bp.current_netto_pris as unit_price,
bp.current_brutto_pris as brutto_price,
'Bygma' as supplier_name,
'bygma' as source,
bp.is_active,
bp.last_seen,
CONCAT('VareNr: ', bp.vareNr, ' | Brutto: ', bp.current_brutto_pris, ' DKK') as description
FROM bygma_products bp
WHERE bp.is_active = 1
`;
const bygmaParams = [];
if (category && category !== 'alle') {
bygmaQuery += ' AND bp.varegrp = ?';
bygmaParams.push(category);
}
if (search) {
bygmaQuery += ' AND (bp.tekst LIKE ? OR bp.vareNr LIKE ?)';
bygmaParams.push(`%${search}%`, `%${search}%`);
}
// Get total count for Bygma
try {
const [countResult] = await databaseService.query(
`SELECT COUNT(*) as total FROM bygma_products bp WHERE bp.is_active = 1 ${category && category !== 'alle' ? 'AND bp.varegrp = ?' : ''} ${search ? 'AND (bp.tekst LIKE ? OR bp.vareNr LIKE ?)' : ''}`,
search ? (category && category !== 'alle' ? [category, `%${search}%`, `%${search}%`] : [`%${search}%`, `%${search}%`]) : (category && category !== 'alle' ? [category] : [])
);
totalCount = countResult.total;
} catch (e) {
console.error('Count query error:', e);
totalCount = 0;
}
bygmaQuery += ` ORDER BY bp.tekst LIMIT ? OFFSET ?`;
bygmaParams.push(limit, offset);
try {
const bygmaMaterials = await databaseService.query(bygmaQuery, bygmaParams);
// Bygma category mapping for display names
const bygmaCategoryMapping = {
'5320': 'Arbejdstøj og sikkerhedsudstyr',
'5530': 'Håndværktøj og værktøj',
'5330': 'Måleværktøj og tilbehør',
'5160': 'Skruer og beslag',
'5170': 'Søm og fastgørelse',
'5610': 'Elektrisk værktøj og tilbehør',
'5550': 'Specialværktøj',
'5180': 'Lim og bindemidler',
'5510': 'Snittværktøj og klinger',
'4750': 'Stilladser og understøtning',
'4810': 'Byggematerialer og plader',
'3210': 'Befæstninger og metal',
'5340': 'Opmåling og afmærkning',
'5520': 'Slibe- og polérværktøj',
'5150': 'Rustfrit stål produkter',
'5540': 'Batterier og akku værktøj',
'5490': 'Rengøring og vedligeholdelse',
'5140': 'Maskiner og anlæg',
'5350': 'Fuge- og spartelmateriale',
'5430': 'Opbevaring og transport',
// Tilføjet ekstra kategorier fra database analyse
'5560': 'El-installationer og kabler',
'4760': 'Isolering og byggeplader',
'5310': 'Tømmerforbindelser',
'5050': 'VVS og rør',
'1000': 'Trælast og byggeplader',
'3610': 'Metalbefæstning',
'4840': 'Tagmaterialer',
'7030': 'Maling og overfladebehandling',
'4860': 'Vandtætning og tagpap',
'7020': 'Spartel og fugemasse',
'5570': 'Elektriske komponenter',
'4880': 'Facadematerialer',
'6150': 'Ventilation og luftbehandling',
'4780': 'Terrassematerialer',
'7010': 'Lak og træbeskyttelse',
'3510': 'Rustfrit stål systemer',
// Ekstra store kategorier
'7560': 'Haveredskaber og udendørs',
'6110': 'Varme og klimaanlæg',
'3410': 'Industrimetal',
'1005': 'Konstruktionstræ',
'6120': 'Ventilationskomponenter',
// Samme mapping som kategori API
'6140': 'Varmepumper', '4890': 'Gulvmaterialer', '7320': 'Facade maling', '6310': 'VVS',
'3230': 'Stålprofiler', '4740': 'Døre/vinduer', '4820': 'Trælast', '3820': 'Ventiler',
'1037': 'Krydsfiner', '6160': 'Luftfiltre', '3330': 'Kæder', '7250': 'Pensler',
'3310': 'Rør', '1006': 'Brædder', '3520': 'Aluminium', '1007': 'Lister',
'3740': 'Metalbearbejdning', '5420': 'Vedligeholdelse', '6130': 'Radiatorer',
'3620': 'Svejsning', '5060': 'Hydraulik', '5470': 'Førstehjælp', '5690': 'Elektro',
'7570': 'Have', '9930': 'Service', '1074': 'Laminat', '7520': 'Gulvbelægning',
'3360': 'Søjler', '6170': 'Klima', '7290': 'Malerværktøj', '4790': 'Facade',
'4770': 'Tag', '1009': 'Spånplader', '6350': 'Køkken', '8890': 'Analyse',
'1045': 'Isolering', '6390': 'VVS værktøj', '9940': 'Reservedele', '7310': 'Grundere',
'1036': 'OSB', '1044': 'Gips', '8820': 'Lab', '3860': 'Industri', '5480': 'Værksted',
'6320': 'Sanitet', '1048': 'Mineraluld', '3190': 'Metal div', '3530': 'Kobber',
'3350': 'Plader', '1043': 'MDF', '5620': 'El-inst', '3730': 'Metal værktøj',
'5010': 'Maskiner', '3810': 'Flanger', '3840': 'Pumper', '7510': 'Tapet',
'5410': 'Rengøring', '8690': 'Special værktøj', '5440': 'Emballage', '4630': 'Paneler',
'7390': 'Rust', '5020': 'Kompressor', '5030': 'Pumper vand', '5040': 'Generator',
'5450': 'Kontor', '4850': 'Tagpap', '3320': 'Jernvarer', '5310': 'Tømmerforbindelser',
// KOMPLET mapping - alle resterende kategorier (samme som kategori API)
'9121': 'Teknisk rådgivning', '6250': 'Ventilationsstyring', '1014': 'Konstruktionstræ special',
'1070': 'Gulvbelægning træ', '1038': 'Profiler og lister', '1046': 'Lyddæmpning',
'7330': 'Maling special', '8840': 'Automatisering', '6340': 'Køleteknik',
'3550': 'Aluminium special', '3130': 'Stålbearbejdning', '6360': 'Klimastyring',
'8410': 'Kontroludstyr', '7690': 'Specialmaling', '6330': 'Køl og frys',
'1030': 'Byggeplader special', '8310': 'Måleudstyr', '4150': 'Byggeteknisk',
'9990': 'Specialordrer', '4610': 'Loft systemer', '6260': 'Varmedistribution',
'7550': 'Gulvfinish', '8490': 'Sikkerhedssystemer', '3260': 'Metalbeslag',
'1027': 'Isoleringsløsninger', '8610': 'Kommunikationssystemer', '3720': 'Svejseteknik',
'9120': 'Projektledelse', '1035': 'Bygningsmaterialer', '6370': 'Klimateknologi',
'1016': 'Trækomponenter', '9100': 'Konsulentydelser', '8390': 'Testudstyr',
'7540': 'Gulvvedligeholdelse', '4910': 'Byggeelementer', '2000': 'Byggematerialer basis',
'8810': 'Dataopsamling', '6290': 'Varmeregulering', '3850': 'Metalkomponenter',
'8440': 'Sikkerhedsudstyr avanceret', '7190': 'Overfladebehandling', '5130': 'Maskinkomponenter',
'8830': 'Processtyring', '4050': 'Fundamentelementer', '9900': 'Diverse services',
'1040': 'Byggeelementer træ', '6190': 'Varmeteknik', '8530': 'Overvågningssystemer',
'7420': 'Vedligeholdelsesmaling'
};
allMaterials = bygmaMaterials.map(material => ({
id: `bygma_${material.id}`,
name: material.name || 'Ukendt Bygma produkt',
category: bygmaCategoryMapping[material.category] || material.category,
unit: material.unit || 'stk',
unit_price: parseFloat(material.unit_price || 0),
brutto_price: parseFloat(material.brutto_price || 0),
supplier_name: 'Bygma',
description: material.description,
source: 'bygma',
product_code: material.product_code,
last_seen: material.last_seen,
is_active: material.is_active,
canEdit: false // Bygma produkter kan ikke redigeres
}));
} catch (e) {
console.error('Bygma query error:', e);
allMaterials = [];
}
}
// Hent built-in materialer hvis der er plads (supplement)
const remainingSlots = limit - allMaterials.length;
if (remainingSlots > 0) {
let builtInQuery = `
SELECT
mp.id,
mp.name,
mp.category,
mp.unit,
mp.price as unit_price,
mp.supplier_name,
'built_in' as source,
mp.is_active,
mp.created_at,
mp.parse_log as description
FROM material_prices mp
WHERE mp.is_active = 1
`;
const builtInParams = [];
if (category && category !== 'alle') {
builtInQuery += ' AND mp.category = ?';
builtInParams.push(category);
}
if (search) {
builtInQuery += ' AND mp.name LIKE ?';
builtInParams.push(`%${search}%`);
}
builtInQuery += ` ORDER BY mp.name LIMIT ?`;
builtInParams.push(remainingSlots);
try {
const builtInMaterials = await databaseService.query(builtInQuery, builtInParams);
const processedBuiltIn = builtInMaterials.map(material => ({
id: `builtin_${material.id}`,
name: material.name || 'Ukendt materiale',
category: material.category || 'øvrige',
unit: material.unit || 'stk',
unit_price: parseFloat(material.unit_price || 0),
supplier_name: material.supplier_name || '',
description: material.description || '',
source: 'built_in',
is_active: material.is_active,
canEdit: true // Built-in produkter kan redigeres
}));
allMaterials = allMaterials.concat(processedBuiltIn);
} catch (e) {
console.error('Built-in query error:', e);
}
}
const responseData = {
success: true,
data: allMaterials,
pagination: {
total: totalCount,
limit: limit,
page: page,
offset: offset,
pages: Math.ceil(totalCount / limit),
hasMore: offset + allMaterials.length < totalCount
},
source_info: {
bygma_products: includeBygma ? allMaterials.filter(m => m.source === 'bygma').length : 0,
builtin_products: allMaterials.filter(m => m.source === 'built_in').length,
total_in_response: allMaterials.length
}
};
// Cache hele datasættet for simple queries (først gang der hentes alle data)
if (useCache && !materialsCache.isValid()) {
console.log('💾 Building full materials cache...');
// Hent ALLE materialer til cache (uden limit)
let cacheQuery = `
SELECT
bp.id,
bp.vareNr as product_code,
bp.tekst as name,
bp.varegrp as category,
bp.enhed as unit,
bp.current_netto_pris as unit_price,
bp.current_brutto_pris as brutto_price,
'Bygma' as supplier_name,
'bygma' as source,
bp.is_active,
bp.last_seen,
CONCAT('VareNr: ', bp.vareNr, ' | Brutto: ', bp.current_brutto_pris, ' DKK') as description
FROM bygma_products bp
WHERE bp.is_active = 1
ORDER BY bp.tekst
`;
try {
const allCacheMaterials = await databaseService.query(cacheQuery);
const processedCacheMaterials = allCacheMaterials.map(material => ({
id: `bygma_${material.id}`,
name: material.name || 'Ukendt materiale',
category: getCategoryDisplayName(material.category, globalBygmaCategoryMapping) || material.category || 'øvrige',
unit: material.unit || 'stk',
unit_price: parseFloat(material.unit_price || 0),
brutto_price: parseFloat(material.brutto_price || 0),
supplier_name: material.supplier_name,
description: material.description || '',
source: 'bygma',
product_code: material.product_code,
last_seen: material.last_seen,
is_active: material.is_active,
canEdit: false
}));
materialsCache.set({
materials: processedCacheMaterials,
totalCount: processedCacheMaterials.length,
source_info: {
bygma_products: processedCacheMaterials.length,
builtin_products: 0,
total_in_cache: processedCacheMaterials.length
}
});
console.log(`✅ Cache built with ${processedCacheMaterials.length} materials`);
} catch (cacheError) {
console.error('Cache building error:', cacheError);
// Fortsæt med normal query hvis cache fejler
}
}
res.json(responseData);
} catch (error) {
console.error('Error loading materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af materialer: ' + error.message
});
}
});
// Get material categories - kombinerer kategorier fra både Bygma og built-in
app.get('/api/pricing/categories', async (req, res) => {
try {
// Bygma kategori mapping baseret på produktanalyse
const bygmaCategoryMapping = {
'5320': 'Arbejdstøj og sikkerhedsudstyr',
'5530': 'Håndværktøj og værktøj',
'5330': 'Måleværktøj og tilbehør',
'5160': 'Skruer og beslag',
'5170': 'Søm og fastgørelse',
'5610': 'Elektrisk værktøj og tilbehør',
'5550': 'Specialværktøj',
'5180': 'Lim og bindemidler',
'5510': 'Snittværktøj og klinger',
'4750': 'Stilladser og understøtning',
'4810': 'Byggematerialer og plader',
'3210': 'Befæstninger og metal',
'5340': 'Opmåling og afmærkning',
'5520': 'Slibe- og polérværktøj',
'5150': 'Rustfrit stål produkter',
'5540': 'Batterier og akku værktøj',
'5490': 'Rengøring og vedligeholdelse',
'5140': 'Maskiner og anlæg',
'5350': 'Fuge- og spartelmateriale',
'5430': 'Opbevaring og transport',
// Tilføjet ekstra kategorier fra database analyse
'5560': 'El-installationer og kabler',
'4760': 'Isolering og byggeplader',
'5310': 'Tømmerforbindelser',
'5050': 'VVS og rør',
'1000': 'Trælast og byggeplader',
'3610': 'Metalbefæstning',
'4840': 'Tagmaterialer',
'7030': 'Maling og overfladebehandling',
'4860': 'Vandtætning og tagpap',
'7020': 'Spartel og fugemasse',
'5570': 'Elektriske komponenter',
'4880': 'Facadematerialer',
'6150': 'Ventilation og luftbehandling',
'4780': 'Terrassematerialer',
'7010': 'Lak og træbeskyttelse',
'3510': 'Rustfrit stål systemer',
// Ekstra store kategorier
'7560': 'Haveredskaber og udendørs',
'6110': 'Varme og klimaanlæg',
'3410': 'Industrimetal',
'1005': 'Konstruktionstræ',
'6120': 'Ventilationskomponenter',
// Komplet mapping af store kategorier
'6140': 'Varmepumper', '4890': 'Gulvmaterialer', '7320': 'Facade maling', '6310': 'VVS',
'3230': 'Stålprofiler', '4740': 'Døre/vinduer', '4820': 'Trælast', '3820': 'Ventiler',
'1037': 'Krydsfiner', '6160': 'Luftfiltre', '3330': 'Kæder', '7250': 'Pensler',
'3310': 'Rør', '1006': 'Brædder', '3520': 'Aluminium', '1007': 'Lister',
'3740': 'Metalbearbejdning', '5420': 'Vedligeholdelse', '6130': 'Radiatorer',
'3620': 'Svejsning', '5060': 'Hydraulik', '5470': 'Førstehjælp', '5690': 'Elektro',
'7570': 'Have', '9930': 'Service', '1074': 'Laminat', '7520': 'Gulvbelægning',
'3360': 'Søjler', '6170': 'Klima', '7290': 'Malerværktøj', '4790': 'Facade',
'4770': 'Tag', '1009': 'Spånplader', '6350': 'Køkken', '8890': 'Analyse',
'1045': 'Isolering', '6390': 'VVS værktøj', '9940': 'Reservedele', '7310': 'Grundere',
'1036': 'OSB', '1044': 'Gips', '8820': 'Lab', '3860': 'Industri', '5480': 'Værksted',
'6320': 'Sanitet', '1048': 'Mineraluld', '3190': 'Metal div', '3530': 'Kobber',
'3350': 'Plader', '1043': 'MDF', '5620': 'El-inst', '3730': 'Metal værktøj',
'5010': 'Maskiner', '3810': 'Flanger', '3840': 'Pumper', '7510': 'Tapet',
'5410': 'Rengøring', '8690': 'Special værktøj', '5440': 'Emballage', '4630': 'Paneler',
'7390': 'Rust', '5020': 'Kompressor', '5030': 'Pumper vand', '5040': 'Generator',
'5450': 'Kontor', '4850': 'Tagpap', '3320': 'Jernvarer', '5310': 'Tømmerforbindelser',
// KOMPLET mapping - alle resterende kategorier
'9121': 'Teknisk rådgivning', '6250': 'Ventilationsstyring', '1014': 'Konstruktionstræ special',
'1070': 'Gulvbelægning træ', '1038': 'Profiler og lister', '1046': 'Lyddæmpning',
'7330': 'Maling special', '8840': 'Automatisering', '6340': 'Køleteknik',
'3550': 'Aluminium special', '3130': 'Stålbearbejdning', '6360': 'Klimastyring',
'8410': 'Kontroludstyr', '7690': 'Specialmaling', '6330': 'Køl og frys',
'1030': 'Byggeplader special', '8310': 'Måleudstyr', '4150': 'Byggeteknisk',
'9990': 'Specialordrer', '4610': 'Loft systemer', '6260': 'Varmedistribution',
'7550': 'Gulvfinish', '8490': 'Sikkerhedssystemer', '3260': 'Metalbeslag',
'1027': 'Isoleringsløsninger', '8610': 'Kommunikationssystemer', '3720': 'Svejseteknik',
'9120': 'Projektledelse', '1035': 'Bygningsmaterialer', '6370': 'Klimateknologi',
'1016': 'Trækomponenter', '9100': 'Konsulentydelser', '8390': 'Testudstyr',
'7540': 'Gulvvedligeholdelse', '4910': 'Byggeelementer', '2000': 'Byggematerialer basis',
'8810': 'Dataopsamling', '6290': 'Varmeregulering', '3850': 'Metalkomponenter',
'8440': 'Sikkerhedsudstyr avanceret', '7190': 'Overfladebehandling', '5130': 'Maskinkomponenter',
'8830': 'Processtyring', '4050': 'Fundamentelementer', '9900': 'Diverse services',
'1040': 'Byggeelementer træ', '6190': 'Varmeteknik', '8530': 'Overvågningssystemer',
'7420': 'Vedligeholdelsesmaling'
};
// Hent kategorier fra Bygma produkter
const bygmaCategories = await databaseService.query(`
SELECT DISTINCT varegrp as category, COUNT(*) as count
FROM bygma_products
WHERE is_active = 1 AND varegrp IS NOT NULL AND varegrp != ''
GROUP BY varegrp
ORDER BY count DESC, varegrp
`);
// Hent kategorier fra built-in materialer
const builtinCategories = await databaseService.query(`
SELECT DISTINCT category, COUNT(*) as count
FROM material_prices
WHERE is_active = 1 AND category IS NOT NULL AND category != ''
GROUP BY category
ORDER BY count DESC, category
`);
// Kombiner og fjern duplikater
const categoryMap = new Map();
bygmaCategories.forEach(cat => {
const displayName = bygmaCategoryMapping[cat.category] || `Bygma ${cat.category}`;
categoryMap.set(cat.category, {
name: cat.category,
displayName: displayName,
count: (categoryMap.get(cat.category)?.count || 0) + cat.count,
sources: ['bygma']
});
});
builtinCategories.forEach(cat => {
if (categoryMap.has(cat.category)) {
categoryMap.get(cat.category).count += cat.count;
categoryMap.get(cat.category).sources.push('built_in');
} else {
categoryMap.set(cat.category, {
name: cat.category,
displayName: cat.category, // Built-in kategorier er allerede menneskeligt læselige
count: cat.count,
sources: ['built_in']
});
}
});
const categories = Array.from(categoryMap.values()).sort((a, b) => b.count - a.count);
res.json({
success: true,
data: categories
});
} catch (error) {
console.error('Error loading categories:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af kategorier'
});
}
});
app.post('/api/pricing/materials', async (req, res) => {
try {
const { name, category, unit, unit_price, supplier_name, description } = req.body;
if (!name || !unit_price) {
return res.status(400).json({
success: false,
error: 'Navn og pris er påkrævet'
});
}
// First insert material
const materialResult = await databaseService.query(`
INSERT INTO materials (
name, category, unit, description, created_at, updated_at
) VALUES (?, ?, ?, ?, NOW(), NOW())
`, [name, category || null, unit || 'stk', description || null]);
// Then insert material price
const priceResult = await databaseService.query(`
INSERT INTO material_prices (
material_id, name, category, unit, price, supplier_name, parse_log,
valid_from, is_active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURDATE(), 1, NOW())
`, [materialResult.insertId, name, category || null, unit || 'stk', parseFloat(unit_price), supplier_name || null, description || null]);
// Clear materials cache when new material is added
materialsCache.clear();
console.log('🗑️ Materials cache cleared after adding new material');
res.json({
success: true,
id: priceResult.insertId,
material_id: materialResult.insertId
});
} catch (error) {
console.error('Error adding material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af materiale'
});
}
});
// Update material
app.put('/api/pricing/materials/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, category, unit, unit_price, supplier_name, description } = req.body;
// Get the material_id from material_prices table
const priceRows = await databaseService.query(
'SELECT material_id FROM material_prices WHERE id = ?',
[id]
);
if (priceRows.length === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
const materialId = priceRows[0].material_id;
// Update materials table
await databaseService.query(`
UPDATE materials
SET name = ?, category = ?, unit = ?, description = ?, updated_at = NOW()
WHERE id = ?
`, [name, category || null, unit || 'stk', description || null, materialId]);
// Update material_prices table
await databaseService.query(`
UPDATE material_prices
SET unit_price = ?, supplier_name = ?, updated_at = NOW()
WHERE id = ?
`, [parseFloat(unit_price), supplier_name || null, id]);
res.json({
success: true,
message: 'Materiale opdateret'
});
} catch (error) {
console.error('Error updating material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materiale'
});
}
});
// Delete material
app.delete('/api/pricing/materials/:id', async (req, res) => {
try {
const { id } = req.params;
// Get the material_id from material_prices table
const priceRows = await databaseService.query(
'SELECT material_id FROM material_prices WHERE id = ?',
[id]
);
if (priceRows.length === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
const materialId = priceRows[0].material_id;
// Delete from material_prices table
await databaseService.query('DELETE FROM material_prices WHERE id = ?', [id]);
// Check if this material is used in other price entries
const otherPrices = await databaseService.query(
'SELECT COUNT(*) as count FROM material_prices WHERE material_id = ?',
[materialId]
);
// If no other price entries exist, delete from materials table too
if (otherPrices[0].count === 0) {
await databaseService.query('DELETE FROM materials WHERE id = ?', [materialId]);
}
res.json({
success: true,
message: 'Materiale slettet'
});
} catch (error) {
console.error('Error deleting material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materiale'
});
}
});
// ========================================
// MATERIALS API ENDPOINTS (KOMBINERET BYGMA + BUILT-IN)
// ========================================
// Add new material (kun built-in - Bygma er read-only)
app.post('/api/pricing/materials', async (req, res) => {
try {
const { name, category, unit, unit_price, supplier_name, description } = req.body;
if (!name || !unit_price) {
return res.status(400).json({
success: false,
error: 'Navn og pris er påkrævet'
});
}
// Insert into material_prices table
const result = await databaseService.query(`
INSERT INTO material_prices (
name, category, unit, price, supplier_name, parse_log,
valid_from, is_active, created_at
) VALUES (?, ?, ?, ?, ?, ?, CURDATE(), 1, NOW())
`, [
name,
category || null,
unit || 'stk',
parseFloat(unit_price),
supplier_name || null,
description || null
]);
res.json({
success: true,
id: `builtin_${result.insertId}`,
message: 'Materiale tilføjet succesfuldt'
});
} catch (error) {
console.error('Error adding material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af materiale: ' + error.message
});
}
});
// Update material (kun built-in materialer)
app.put('/api/pricing/materials/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, category, unit, unit_price, supplier_name, description } = req.body;
// Check if it's a Bygma product (read-only)
if (id.startsWith('bygma_')) {
return res.status(403).json({
success: false,
error: 'Bygma produkter kan ikke redigeres'
});
}
// Extract actual ID for built-in materials
const actualId = id.replace('builtin_', '');
// Update material_prices table
const result = await databaseService.query(`
UPDATE material_prices
SET name = ?, category = ?, unit = ?, price = ?, supplier_name = ?, parse_log = ?
WHERE id = ?
`, [name, category || null, unit || 'stk', parseFloat(unit_price), supplier_name || null, description || null, actualId]);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
res.json({
success: true,
message: 'Materiale opdateret succesfuldt'
});
} catch (error) {
console.error('Error updating material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materiale: ' + error.message
});
}
});
// Delete material (kun built-in materialer)
app.delete('/api/pricing/materials/:id', async (req, res) => {
try {
const { id } = req.params;
// Check if it's a Bygma product (read-only)
if (id.startsWith('bygma_')) {
return res.status(403).json({
success: false,
error: 'Bygma produkter kan ikke slettes'
});
}
// Extract actual ID for built-in materials
const actualId = id.replace('builtin_', '');
// Delete from material_prices table
const result = await databaseService.query('DELETE FROM material_prices WHERE id = ?', [actualId]);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
res.json({
success: true,
message: 'Materiale slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materiale: ' + error.message
});
}
});
// Material statistics endpoint
app.get('/api/pricing/materials/stats', async (req, res) => {
try {
// Get Bygma statistics
const [bygmaStats] = await databaseService.query(`
SELECT
COUNT(*) as total_products,
COUNT(DISTINCT varegrp) as total_categories,
AVG(current_netto_pris) as avg_price,
MIN(current_netto_pris) as min_price,
MAX(current_netto_pris) as max_price,
'bygma' as source
FROM bygma_products
WHERE is_active = 1
`);
// Get built-in statistics
const [builtinStats] = await databaseService.query(`
SELECT
COUNT(*) as total_products,
COUNT(DISTINCT category) as total_categories,
AVG(price) as avg_price,
MIN(price) as min_price,
MAX(price) as max_price,
'built_in' as source
FROM material_prices
WHERE is_active = 1
`);
// Get top categories
const topCategories = await databaseService.query(`
SELECT category_name, total_products FROM (
SELECT varegrp as category_name, COUNT(*) as total_products FROM bygma_products WHERE is_active = 1 GROUP BY varegrp
UNION ALL
SELECT category as category_name, COUNT(*) as total_products FROM material_prices WHERE is_active = 1 GROUP BY category
) combined
WHERE category_name IS NOT NULL
GROUP BY category_name
ORDER BY SUM(total_products) DESC
LIMIT 10
`);
res.json({
success: true,
data: {
bygma: bygmaStats || {},
built_in: builtinStats || {},
total_products: (bygmaStats?.total_products || 0) + (builtinStats?.total_products || 0),
top_categories: topCategories
}
});
} catch (error) {
console.error('Error loading material statistics:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af statistik'
});
}
});
// Categories API endpoint (legacy support)
app.get('/api/categories', async (req, res) => {
try {
// Redirect to new categories endpoint
const categories = await databaseService.query(`
SELECT DISTINCT category
FROM material_prices
WHERE category IS NOT NULL AND category != ''
ORDER BY category
`);
const categoryList = categories.map(row => row.category);
res.json(categoryList);
} catch (error) {
console.error('Error loading categories:', error);
res.status(500).json({ error: 'Fejl ved indlæsning af kategorier' });
}
});
// Uploads API endpoints
app.get('/api/uploads/recent', async (req, res) => {
try {
// This is a simple implementation - in reality you'd want to track uploads in DB
res.json({ files: [] });
} catch (error) {
console.error('Error loading uploads:', error);
res.status(500).json({ error: 'Fejl ved indlæsning af uploads' });
}
});
app.post('/api/uploads', async (req, res) => {
try {
// Simple upload handler - in reality you'd want proper file handling
res.json({
success: true,
filename: 'placeholder.pdf',
message: 'Upload funktionalitet ikke implementeret endnu'
});
} catch (error) {
console.error('Error handling upload:', error);
res.status(500).json({
success: false,
error: 'Upload fejlede'
});
}
});
// ====================
// CUSTOMER PROJECTS API
// ====================
// Get all customer projects
app.get('/api/customer-projects/projects', async (req, res) => {
try {
const projects = await databaseService.query(`
SELECT
cp.*,
COUNT(DISTINCT rg.id) as geometry_count,
COUNT(DISTINCT pl.id) as labor_count,
COUNT(DISTINCT pm.id) as materials_count,
COUNT(DISTINCT gq.id) as quotes_count
FROM customer_projects cp
LEFT JOIN roof_geometry rg ON cp.id = rg.project_id
LEFT JOIN project_labor pl ON cp.id = pl.project_id
LEFT JOIN project_materials pm ON cp.id = pm.project_id
LEFT JOIN generated_quotes gq ON cp.id = gq.project_id
GROUP BY cp.id
ORDER BY cp.created_at DESC
`);
res.json({
success: true,
projects: projects
});
} catch (error) {
console.error('Error loading projects:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af projekter'
});
}
});
// Get material categories (doesn't require project validation)
app.get('/api/customer-projects/material-categories', async (req, res) => {
try {
console.log('🏷️ Loading material categories');
// Get unique categories from material_prices table
const categoriesResult = await databaseService.query(`
SELECT DISTINCT category
FROM material_prices
WHERE category IS NOT NULL AND category != ''
ORDER BY category
`);
const categories = categoriesResult.map(row => row.category);
console.log(`📦 Found ${categories.length} material categories:`, categories);
res.json({
success: true,
categories
});
} catch (error) {
console.error('Error getting material categories:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af material kategorier'
});
}
});
// Create new customer project
app.post('/api/customer-projects/projects', async (req, res) => {
try {
const { customerName, customerEmail, customerPhone, projectName, projectDescription, projectAddress } = req.body;
// Validate required fields
if (!customerName || !projectName) {
return res.status(400).json({
success: false,
error: 'Kunde navn og projekt navn er påkrævet'
});
}
const result = await databaseService.query(`
INSERT INTO customer_projects (
customer_name, customer_email, customer_phone,
project_name, project_description, customer_address,
project_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'draft', NOW(), NOW())
`, [customerName, customerEmail, customerPhone, projectName, projectDescription, projectAddress]);
// Get the complete project object to return to frontend
const newProject = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[result.insertId]
);
console.log('✅ New project created:', newProject[0]);
res.json({
success: true,
project: newProject[0], // Return complete project object
projectId: result.insertId,
message: 'Projekt oprettet succesfuldt'
});
} catch (error) {
console.error('Error creating project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved oprettelse af projekt'
});
}
});
// Get project step-by-step breakdown for manual review
app.get('/api/customer-projects/:projectId/breakdown', async (req, res) => {
try {
const { projectId } = req.params;
// Get project basic info
const projectResult = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (projectResult.length === 0) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
const project = projectResult[0];
// Get geometry
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Get materials
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ?',
[projectId]
);
// Get labor
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
// Get saved calculations - use existing table structure
let calculations = [];
try {
calculations = await databaseService.query(
'SELECT * FROM saved_calculations WHERE project_id = ? ORDER BY created_at DESC',
[projectId],
{ silenceErrors: true }
);
} catch (error) {
// Table doesn't exist, use empty array - this is expected for now
// console.log('saved_calculations table not found, using empty array');
}
// Get all quotes for this project
let quotes = [];
try {
quotes = await databaseService.query(
'SELECT * FROM project_quotes WHERE project_id = ? ORDER BY created_at DESC',
[projectId],
{ silenceErrors: true }
);
} catch (error) {
// If column doesn't exist, use empty array - this is expected for now
// console.log('project_quotes project_id column not found, trying alternative query');
}
res.json({
success: true,
breakdown: {
project,
geometry: geometry[0] || null,
materials,
labor,
calculations,
quotes,
steps: {
'1_project_created': {
completed: true,
data: project,
timestamp: project.created_at
},
'2_geometry_defined': {
completed: geometry.length > 0,
data: geometry[0] || null,
timestamp: geometry[0]?.created_at || null
},
'3_materials_selected': {
completed: materials.length > 0,
data: materials,
timestamp: materials[0]?.created_at || null
},
'4_labor_calculated': {
completed: labor.length > 0,
data: labor,
timestamp: labor[0]?.created_at || null
},
'5_calculations_saved': {
completed: calculations.length > 0,
data: calculations,
timestamp: calculations[0]?.created_at || null
},
'6_quotes_generated': {
completed: quotes.length > 0,
data: quotes,
timestamp: quotes[0]?.created_at || null
}
}
}
});
} catch (error) {
console.error('Error getting project breakdown:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt breakdown'
});
}
});
// Get all quotes for a project with detailed view
app.get('/api/customer-projects/:projectId/quotes', async (req, res) => {
try {
const { projectId } = req.params;
// Get all quotes for the project from metadata
const quotes = await databaseService.query(`
SELECT id, project_type, customer_name, total_incl_vat, material_cost,
labor_cost, created_at, notes, metadata
FROM project_quotes
WHERE JSON_EXTRACT(metadata, '$.project_id') = ?
ORDER BY created_at DESC
`, [projectId]);
// Parse metadata for each quote
const quotesWithDetails = quotes.map(quote => {
let metadata = {};
try {
metadata = JSON.parse(quote.metadata || '{}');
} catch (e) {
metadata = {};
}
return {
...quote,
quote_text: metadata.quote_text || 'Ikke tilgængelig',
labor_hours: metadata.labor_hours || 0,
materials_used: metadata.materials_used || [],
calculation_method: metadata.calculation_method || 'Ukendt',
delivered_to_customer: metadata.delivered_to_customer || false,
customer_response: metadata.customer_response || 'Afventer svar'
};
});
res.json({
success: true,
quotes: quotesWithDetails,
project_id: projectId,
count: quotesWithDetails.length
});
} catch (error) {
console.error('Error getting project quotes:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt tilbud'
});
}
});
// Get project details
app.get('/api/customer-projects/:projectId', async (req, res) => {
try {
const { projectId } = req.params;
const [project] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (!project) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
// Get related data
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ? ORDER BY created_at',
[projectId]
);
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ? ORDER BY created_at',
[projectId]
);
const calculations = await databaseService.query(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
const quotes = await databaseService.query(
'SELECT * FROM generated_quotes WHERE project_id = ? ORDER BY created_at DESC',
[projectId]
);
res.json({
success: true,
project: {
...project,
geometry: geometry,
labor: labor,
materials: materials,
latestCalculation: calculations[0] || null,
quotes: quotes
}
});
} catch (error) {
console.error('Error loading project details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af projekt detaljer'
});
}
});
// Update project
app.put('/api/customer-projects/:projectId', async (req, res) => {
try {
const { projectId } = req.params;
const { projectName, customerName, customerEmail, customerPhone, projectAddress, projectDescription, project_status } = req.body;
console.log(`[PROJECT UPDATE] ProjectId: ${projectId}, Body:`, req.body);
// Validate projectId
if (!projectId || isNaN(projectId)) {
return res.status(400).json({
success: false,
error: 'Ugyldigt projekt ID'
});
}
// Handle different types of updates
let query, params;
if (project_status && !projectName) {
// Only updating status
console.log(`[STATUS UPDATE] Updating project ${projectId} status to: ${project_status}`);
query = `UPDATE customer_projects SET project_status = ?, updated_at = NOW() WHERE id = ?`;
params = [project_status, projectId];
} else if (projectName) {
// Full project update
console.log(`[FULL UPDATE] Updating project ${projectId} with all fields`);
query = `UPDATE customer_projects
SET project_name = ?, customer_name = ?, customer_email = ?,
customer_phone = ?, project_address = ?, project_description = ?,
updated_at = NOW()
WHERE id = ?`;
params = [projectName, customerName, customerEmail, customerPhone, projectAddress, projectDescription, projectId];
} else {
return res.status(400).json({
success: false,
error: 'Ingen data at opdatere - skal have enten project_status eller projektdetaljer'
});
}
console.log(`[SQL QUERY] ${query}`);
console.log(`[SQL PARAMS]`, params);
const result = await databaseService.query(query, params);
console.log(`[UPDATE RESULT]`, result);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: `Projekt med ID ${projectId} ikke fundet eller ingen ændringer foretaget`
});
}
// Get updated project
const [updatedProject] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
console.log(`[UPDATED PROJECT]`, updatedProject);
res.json({
success: true,
project: updatedProject
});
} catch (error) {
console.error('[PROJECT UPDATE ERROR]', error);
res.status(500).json({
success: false,
error: `Fejl ved opdatering af projekt: ${error.message}`
});
}
});
// Delete project
app.delete('/api/customer-projects/:projectId', async (req, res) => {
try {
const { projectId } = req.params;
// Delete related data first (foreign key constraints)
await databaseService.query('DELETE FROM roof_geometry WHERE project_id = ?', [projectId]);
await databaseService.query('DELETE FROM project_labor WHERE project_id = ?', [projectId]);
await databaseService.query('DELETE FROM project_materials WHERE project_id = ?', [projectId]);
await databaseService.query('DELETE FROM project_calculations WHERE project_id = ?', [projectId]);
await databaseService.query('DELETE FROM generated_quotes WHERE project_id = ?', [projectId]);
// Delete main project
const result = await databaseService.query(
'DELETE FROM customer_projects WHERE id = ?',
[projectId]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
res.json({
success: true,
message: 'Projekt slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af projekt'
});
}
});
// Get project geometry
app.get('/api/customer-projects/:projectId/geometry', async (req, res) => {
try {
const { projectId } = req.params;
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
res.json({
success: true,
geometry: geometry[0] || null
});
} catch (error) {
console.error('Error loading project geometry:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af geometri'
});
}
});
// Get project labor
app.get('/api/customer-projects/:projectId/labor', async (req, res) => {
try {
const { projectId } = req.params;
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ? ORDER BY created_at',
[projectId]
);
res.json({
success: true,
labor: labor
});
} catch (error) {
console.error('Error loading project labor:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af arbejde'
});
}
});
// Get project materials
app.get('/api/customer-projects/:projectId/materials', async (req, res) => {
try {
const { projectId } = req.params;
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ? ORDER BY created_at',
[projectId]
);
res.json({
success: true,
materials: materials
});
} catch (error) {
console.error('Error loading project materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af materialer'
});
}
});
// Get project calculation
app.get('/api/customer-projects/:projectId/calculation', async (req, res) => {
try {
const { projectId } = req.params;
const calculations = await databaseService.query(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
res.json({
success: true,
data: calculations[0] || null
});
} catch (error) {
console.error('Error loading project calculation:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af beregning'
});
}
});
// Save roof geometry
app.post('/api/customer-projects/:projectId/geometry', async (req, res) => {
try {
const { projectId: rawProjectId } = req.params;
const projectId = parseInt(rawProjectId, 10);
console.log('🔍 Geometry save request received:', {
rawProjectId: rawProjectId,
parsedProjectId: projectId,
body: req.body
});
// Validate projectId is a valid number
if (isNaN(projectId) || projectId <= 0) {
console.log('❌ Invalid project ID:', rawProjectId);
return res.status(400).json({
success: false,
error: `Ugyldigt projekt ID: ${rawProjectId}`,
technical_details: `Project ID must be a positive integer, got: ${rawProjectId}`
});
}
const { roofWidth, roofLength, roofPitch, roofType, complexity, notes } = req.body;
console.log('🔍 Validating geometry data:', {
roofWidth: roofWidth,
roofLength: roofLength,
roofPitch: roofPitch,
roofType: roofType,
complexity: complexity,
notes: notes
});
// Validate input
if (!roofWidth || !roofLength) {
console.log('❌ Validation error: Missing width or length');
return res.status(400).json({
success: false,
error: 'Tag bredde og længde er påkrævet'
});
}
// Verify project exists first
console.log('🔍 Checking if project exists:', projectId);
const projectExists = await databaseService.query(
'SELECT id, project_name, customer_name FROM customer_projects WHERE id = ?',
[projectId]
);
if (projectExists.length === 0) {
console.log('❌ Project not found:', projectId);
return res.status(404).json({
success: false,
error: `Projekt med ID ${projectId} findes ikke i databasen. Vælg venligst et eksisterende projekt.`,
technical_details: `Project ID ${projectId} does not exist in customer_projects table`
});
}
console.log('✅ Project found:', projectExists[0].project_name);
// Validate roof_type against ENUM values
const validRoofTypes = ['fladt_tag', 'skraat_tag', 'mansard', 'komplekst'];
if (roofType && !validRoofTypes.includes(roofType)) {
console.log('❌ Invalid roof type:', roofType);
return res.status(400).json({
success: false,
error: `Ugyldig tagtype: "${roofType}". Gyldige typer: ${validRoofTypes.join(', ')}`,
technical_details: `roof_type '${roofType}' not in ENUM(${validRoofTypes.join(', ')})`
});
}
// Calculate area based on Bolius.dk methodology
// For pitched roofs (sadeltag), use: length × height × 2
// For flat roofs, use: width × length
let area;
if (roofType === 'fladt_tag') {
area = roofWidth * roofLength;
} else {
// For pitched roofs, calculate actual roof surface area
// Convert pitch from degrees to calculate roof height and surface length
const pitchRadians = (roofPitch || 30) * Math.PI / 180;
const roofHeight = (roofWidth / 2) * Math.tan(pitchRadians);
const surfaceLength = (roofWidth / 2) / Math.cos(pitchRadians);
area = surfaceLength * roofLength * 2; // Two roof surfaces
}
console.log('🧮 Calculated area:', area, 'm²');
// Delete existing geometry for this project
console.log('🗑️ Deleting existing geometry...');
await databaseService.query(
'DELETE FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Insert new geometry with calculated roof height
const pitchRadians = (roofPitch || 30) * Math.PI / 180;
const roofHeight = roofType === 'fladt_tag' ? 0 : (roofWidth / 2) * Math.tan(pitchRadians);
console.log('💾 Inserting new geometry:', {
projectId: projectId,
roofWidth: roofWidth,
roofLength: roofLength,
area: area,
roofPitch: roofPitch,
roofType: roofType,
complexity: complexity,
roofHeight: roofHeight,
notes: notes
});
const result = await databaseService.query(`
INSERT INTO roof_geometry (
project_id, width_main, length_main, total_area, roof_pitch,
roof_type, complexity_factor, roof_height, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [projectId, roofWidth, roofLength, area, roofPitch, roofType, complexity, roofHeight, notes]);
console.log('✅ Geometry saved successfully with ID:', result.insertId);
res.json({
success: true,
geometryId: result.insertId,
area: Math.round(area * 100) / 100, // Round to 2 decimals
roofHeight: Math.round(roofHeight * 100) / 100,
message: 'Geometri gemt succesfuldt med Bolius-baseret beregning'
});
} catch (error) {
console.error('❌ GEOMETRY SAVE ERROR:', {
message: error.message,
code: error.code,
errno: error.errno,
sqlState: error.sqlState,
sqlMessage: error.sqlMessage,
stack: error.stack
});
let userFriendlyError = 'Ukendt fejl ved gemning af geometri';
if (error.code === 'ER_NO_REFERENCED_ROW_2') {
userFriendlyError = `Projektet findes ikke i databasen. Prøv at vælge et andet projekt.`;
} else if (error.code === 'ER_DATA_TOO_LONG') {
userFriendlyError = 'Et af felterne indeholder for meget tekst. Prøv at gøre beskrivelsen kortere.';
} else if (error.code === 'ER_BAD_NULL_ERROR') {
userFriendlyError = 'Et påkrævet felt mangler. Tjek at alle felter er udfyldt korrekt.';
} else if (error.code === 'ER_DUP_ENTRY') {
userFriendlyError = 'Der er allerede gemt geometri for dette projekt.';
} else if (error.sqlMessage) {
userFriendlyError = `Database fejl: ${error.sqlMessage}`;
}
res.status(500).json({
success: false,
error: userFriendlyError,
technical_details: process.env.NODE_ENV === 'development' ? {
code: error.code,
message: error.message,
sqlMessage: error.sqlMessage
} : undefined
});
}
});
// Save project labor
app.post('/api/customer-projects/:projectId/labor', async (req, res) => {
try {
const { projectId: rawProjectId } = req.params;
const projectId = parseInt(rawProjectId, 10);
if (isNaN(projectId) || projectId <= 0) {
return res.status(400).json({
success: false,
error: `Ugyldigt projekt ID: ${rawProjectId}`
});
}
const { laborEntries, carpenterCount = 1, specialConditions = '', totalAllocatedHours = 0 } = req.body;
console.log('🔍 Labor save request:', { projectId, laborEntries, carpenterCount, specialConditions, totalAllocatedHours });
// Calculate totals from labor entries
let totalHours = 0;
let totalAllocated = 0;
const workBreakdown = [];
const allocatedBreakdown = [];
if (laborEntries && Array.isArray(laborEntries)) {
for (const entry of laborEntries) {
const hours = parseFloat(entry.estimatedHours) || 0;
const allocatedHours = parseFloat(entry.allocatedHours) || 0;
totalHours += hours;
totalAllocated += allocatedHours;
workBreakdown.push({
task: entry.taskDescription || 'Uspecificeret opgave',
hours: hours,
rate: entry.hourlyRate || 580,
cost: hours * (entry.hourlyRate || 580),
notes: entry.notes || ''
});
allocatedBreakdown.push({
task: entry.taskDescription || 'Uspecificeret opgave',
allocatedHours: allocatedHours,
rate: entry.hourlyRate || 580,
cost: allocatedHours * (entry.hourlyRate || 580),
notes: entry.notes || ''
});
}
}
const hoursPerCarpenter = totalHours / carpenterCount;
const hourlyRate = 580; // Fixed rate
const totalCost = totalHours * hourlyRate;
// Delete existing labor entries for this project
await databaseService.query(
'DELETE FROM project_labor WHERE project_id = ?',
[projectId]
);
// Insert new labor entry with correct structure
await databaseService.query(`
INSERT INTO project_labor (
project_id, carpenter_count, estimated_hours_per_carpenter,
total_work_hours, hourly_rate, total_labor_cost, work_breakdown,
allocated_hours_breakdown, special_conditions, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
projectId,
carpenterCount,
hoursPerCarpenter,
totalHours,
hourlyRate,
totalCost,
JSON.stringify(workBreakdown),
JSON.stringify(allocatedBreakdown),
specialConditions,
`${workBreakdown.length} arbejdsopgaver registreret. Total allokeret: ${totalAllocated} timer`
]);
console.log('✅ Labor saved successfully');
res.json({
success: true,
labor: {
carpenterCount,
totalHours,
totalAllocated,
hoursPerCarpenter,
hourlyRate,
totalCost,
workBreakdown,
allocatedBreakdown,
specialConditions
},
message: 'Arbejdstimer og allokering gemt succesfuldt'
});
} catch (error) {
console.error('Error saving labor:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gem af arbejdstimer'
});
}
});
// Save project materials
app.post('/api/customer-projects/:projectId/materials', async (req, res) => {
try {
const { projectId: rawProjectId } = req.params;
const projectId = parseInt(rawProjectId, 10);
if (isNaN(projectId) || projectId <= 0) {
return res.status(400).json({
success: false,
error: `Ugyldigt projekt ID: ${rawProjectId}`
});
}
const { materials } = req.body;
console.log('🔍 Materials save request:', { projectId, materials });
if (!materials || !Array.isArray(materials)) {
return res.status(400).json({
success: false,
error: 'Ingen materialer angivet'
});
}
// Delete existing materials for this project
await databaseService.query(
'DELETE FROM project_materials WHERE project_id = ?',
[projectId]
);
// Insert new materials with correct column names
for (const material of materials) {
await databaseService.query(`
INSERT INTO project_materials (
project_id, material_name, material_category, quantity,
unit, unit_price, total_price, supplier, material_source,
notes, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
projectId,
material.materialName || material.name,
material.materialCategory || material.category,
parseFloat(material.quantity) || 0,
material.unit || 'stk',
parseFloat(material.unitPrice) || 0,
(parseFloat(material.quantity) || 0) * (parseFloat(material.unitPrice) || 0),
material.supplier || '',
material.materialSource || 'manual',
material.description || material.notes || ''
]);
}
console.log('✅ Materials saved successfully');
res.json({
success: true,
materialsCount: materials.length,
message: 'Materialer gemt succesfuldt'
});
} catch (error) {
console.error('Error saving materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gem af materialer'
});
}
});
// Delete a specific material from project
app.delete('/api/customer-projects/:projectId/materials/:materialId', async (req, res) => {
try {
const { projectId, materialId } = req.params;
console.log(`🗑️ Deleting material ${materialId} from project ${projectId}`);
const result = await databaseService.query(
'DELETE FROM project_materials WHERE project_id = ? AND id = ?',
[projectId, materialId]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
console.log('✅ Material deleted successfully');
res.json({
success: true,
message: 'Materiale slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materiale'
});
}
});
// Update a specific material in project
app.put('/api/customer-projects/:projectId/materials/:materialId', async (req, res) => {
try {
const { projectId, materialId } = req.params;
const { quantity, unitPrice, supplier, description } = req.body;
console.log(`📝 Updating material ${materialId} in project ${projectId}:`, { quantity, unitPrice, supplier, description });
// Validate required fields
if (!quantity || !unitPrice) {
return res.status(400).json({
success: false,
error: 'Antal og enhedspris er påkrævet'
});
}
// Calculate new total price
const totalPrice = parseFloat(quantity) * parseFloat(unitPrice);
// Update the material
const result = await databaseService.query(`
UPDATE project_materials
SET quantity = ?, unit_price = ?, total_price = ?, supplier = ?, description = ?, updated_at = NOW()
WHERE project_id = ? AND id = ?
`, [quantity, unitPrice, totalPrice, supplier || null, description || null, projectId, materialId]);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
// Get the updated material
const updatedMaterial = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ? AND id = ?',
[projectId, materialId]
);
console.log('✅ Material updated successfully');
res.json({
success: true,
material: updatedMaterial[0],
message: 'Materiale opdateret succesfuldt'
});
} catch (error) {
console.error('Error updating material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materiale'
});
}
});
// Get material suggestions for a project
app.get('/api/customer-projects/:projectId/materials/suggestions', async (req, res) => {
try {
const { projectId } = req.params;
console.log(`🔍 Loading material suggestions for project ${projectId}`);
// Get all available materials from database
const materials = await databaseService.query(`
SELECT
id,
name as materialName,
category as materialCategory,
unit,
price as unitPrice,
supplier_name as supplier,
confidence
FROM material_prices
WHERE is_active = 1
ORDER BY confidence DESC, category, name
LIMIT 50
`);
console.log(`📦 Found ${materials.length} material suggestions`);
res.json({
success: true,
suggestions: materials.map(material => ({
id: material.id,
materialName: material.materialName,
materialCategory: material.materialCategory,
unit: material.unit,
unitPrice: material.unitPrice,
supplier: material.supplier || '',
confidence: material.confidence || 1.0
}))
});
} catch (error) {
console.error('Error fetching material suggestions:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af material forslag'
});
}
});
// Get historical project data for similar tasks
app.get('/api/customer-projects/:projectId/historical-data', async (req, res) => {
try {
const { projectId } = req.params;
const { taskName, category, roofType } = req.query;
console.log(`🕰️ Loading historical data for project ${projectId}, task: ${taskName}`);
let query = `
SELECT
project_name,
task_name,
task_category,
roof_area,
roof_type,
total_hours,
total_material_cost,
total_labor_cost,
materials_used,
completion_date,
customer_satisfaction,
notes
FROM project_history
WHERE 1=1
`;
const params = [];
if (taskName) {
query += ` AND task_name LIKE ?`;
params.push(`%${taskName}%`);
}
if (category) {
query += ` AND task_category = ?`;
params.push(category);
}
if (roofType) {
query += ` AND roof_type = ?`;
params.push(roofType);
}
query += ` ORDER BY completion_date DESC LIMIT 10`;
const historicalProjects = await databaseService.query(query, params);
// Calculate averages for similar projects
let avgHours = 0;
let avgMaterialCost = 0;
let avgLaborCost = 0;
let avgSatisfaction = 0;
if (historicalProjects.length > 0) {
avgHours = historicalProjects.reduce((sum, p) => sum + parseFloat(p.total_hours || 0), 0) / historicalProjects.length;
avgMaterialCost = historicalProjects.reduce((sum, p) => sum + parseFloat(p.total_material_cost || 0), 0) / historicalProjects.length;
avgLaborCost = historicalProjects.reduce((sum, p) => sum + parseFloat(p.total_labor_cost || 0), 0) / historicalProjects.length;
avgSatisfaction = historicalProjects.reduce((sum, p) => sum + parseFloat(p.customer_satisfaction || 0), 0) / historicalProjects.length;
}
console.log(`📊 Found ${historicalProjects.length} similar historical projects`);
res.json({
success: true,
historicalProjects: historicalProjects.map(project => ({
...project,
materials_used: typeof project.materials_used === 'string' ? JSON.parse(project.materials_used) : project.materials_used
})),
averages: {
hours: Math.round(avgHours * 10) / 10,
materialCost: Math.round(avgMaterialCost),
laborCost: Math.round(avgLaborCost),
satisfaction: Math.round(avgSatisfaction * 10) / 10
}
});
} catch (error) {
console.error('Error fetching historical data:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af historiske data'
});
}
});
// Generate quote for project
app.post('/api/customer-projects/:projectId/quote', async (req, res) => {
try {
const { projectId } = req.params;
const { quoteType } = req.body; // 'static' or 'ai'
// Get project data
const [project] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (!project) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
// Get latest calculation for this project
const [calculation] = await databaseService.query(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
if (!calculation) {
return res.status(400).json({
success: false,
error: 'Ingen prisberegning fundet for dette projekt. Lav en prisberegning først.'
});
}
// Use the proper ProjectQuoteGenerationService
let result;
try {
if (quoteType === 'ai' && openaiService && openaiService.openai) {
// Use AI quote generation with proper data
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
result = await projectQuoteService.generateProfessionalQuote(projectId, calculation.id, {
quoteStyle: 'professional',
includeBreakdown: true,
language: 'danish'
});
} else {
// Use static quote generation (fallback if no AI available)
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
} catch (aiError) {
console.log('AI quote generation failed, falling back to static:', aiError.message);
// Fallback to static quote if AI fails
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, null); // No AI service
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
res.json({
success: true,
data: {
quoteId: result.quoteId,
quoteText: result.quoteText,
tokensUsed: result.tokensUsed || 0,
cost: result.cost || 0,
method: quoteType === 'ai' ? 'ai' : 'static'
}
});
} catch (error) {
console.error('Error generating quote:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved generering af tilbud'
});
}
});
// Get work hour statistics for AI estimation
app.get('/api/work-hour-statistics/:roofType', async (req, res) => {
try {
const { roofType } = req.params;
const { areaMin, areaMax } = req.query;
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
const roofService = new RoofGeometryService(databaseService);
const areaRange = areaMin && areaMax ? [parseFloat(areaMin), parseFloat(areaMax)] : undefined;
const statistics = await roofService.getWorkHourStatistics(roofType, areaRange);
res.json({
success: true,
data: statistics
});
} catch (error) {
console.error('Error getting work hour statistics:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Estimate work hours using AI/historical data
app.post('/api/estimate-work-hours', async (req, res) => {
try {
const projectData = req.body;
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
const roofService = new RoofGeometryService(databaseService);
const estimatedHours = await roofService.estimateWorkHoursWithAI(projectData);
res.json({
success: true,
data: {
estimatedHours,
method: 'ai_historical'
}
});
} catch (error) {
console.error('Error estimating work hours:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Generate HTML quote with logos and professional layout
app.post('/api/customer-projects/:projectId/quote/html', async (req, res) => {
try {
const { projectId } = req.params;
// Get project data
const [project] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (!project) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
// Get related data
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ?',
[projectId]
);
// Calculate totals
const laborTotal = labor.reduce((sum, l) => sum + parseFloat(l.total_cost || 0), 0);
const materialsTotal = materials.reduce((sum, m) => sum + parseFloat(m.total_price || 0), 0);
const subtotal = laborTotal + materialsTotal;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Generate HTML quote
const htmlQuote = await quoteTemplateService.generateHtmlQuote({
project,
geometry: geometry[0] || null,
labor,
materials,
totals: {
laborTotal,
materialsTotal,
subtotal,
vat,
total
}
});
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(htmlQuote);
} catch (error) {
console.error('Error generating HTML quote:', error);
res.status(500).json({
success: false,
error: `Fejl ved generering af HTML tilbud: ${error.message}`
});
}
});
// Get PDF quote from database
app.get('/api/quotes/:quoteId/pdf', async (req, res) => {
try {
const { quoteId } = req.params;
// Get quote with PDF data
const [quote] = await databaseService.query(
'SELECT pdf_data, quote_format FROM generated_quotes WHERE id = ?',
[quoteId]
);
if (!quote) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
if (!quote.pdf_data) {
return res.status(404).json({
success: false,
error: 'PDF ikke tilgængelig for dette tilbud'
});
}
// Set headers for PDF download
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="tilbud-${quoteId}.pdf"`);
// Send PDF data
res.send(quote.pdf_data);
} catch (error) {
console.error('Error retrieving PDF:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af PDF'
});
}
});
// PDF generation service with Python backend
class PythonPDFService {
constructor() {
this.tempDir = '/tmp/tilbudgivern-pdfs';
this.ensureTempDir();
}
ensureTempDir() {
const fs = require('fs');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
async generatePDF(quoteData, outputPath) {
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Create temporary JSON file
const tempJsonPath = path.join(this.tempDir, `quote_${Date.now()}.json`);
try {
// Write quote data to temporary JSON file
fs.writeFileSync(tempJsonPath, JSON.stringify(quoteData, null, 2));
return new Promise((resolve, reject) => {
const pythonProcess = spawn('python3', [
'/home/alex/git/tilbudgivern/backend/pdf_generator.py',
tempJsonPath,
outputPath
]);
let stdout = '';
let stderr = '';
pythonProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
pythonProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
pythonProcess.on('close', (code) => {
// Cleanup temp JSON file
try {
fs.unlinkSync(tempJsonPath);
} catch (e) {
console.warn('Could not delete temp file:', tempJsonPath);
}
if (code === 0) {
console.log('✅ PDF generated successfully:', stdout.trim());
resolve({
success: true,
message: stdout.trim(),
outputPath
});
} else {
console.error('❌ PDF generation failed:', stderr);
reject(new Error(`PDF generation failed: ${stderr || 'Unknown error'}`));
}
});
pythonProcess.on('error', (error) => {
console.error('Failed to start Python PDF generator:', error);
reject(error);
});
});
} catch (error) {
// Cleanup temp file on error
try {
fs.unlinkSync(tempJsonPath);
} catch (e) {}
throw error;
}
}
}
const pythonPDFService = new PythonPDFService();
// Generate PDF for existing quote
app.post('/api/quotes/:quoteId/generate-pdf', async (req, res) => {
try {
const { quoteId } = req.params;
// Get quote and related data
const [quote] = await databaseService.query(`
SELECT gq.*, cp.*, pc.*
FROM generated_quotes gq
LEFT JOIN customer_projects cp ON gq.project_id = cp.id
LEFT JOIN project_calculations pc ON gq.calculation_id = pc.id
WHERE gq.id = ?
`, [quoteId]);
if (!quote) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Get project data
const projectId = quote.project_id;
const [project] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ?',
[projectId]
);
// Prepare data for Python PDF generator
const pdfData = {
project: project,
geometry: geometry[0] || null,
labor: labor[0] || null,
materials: materials,
calculation: {
total_labor_cost: parseFloat(quote.total_labor_cost || 0),
total_material_cost: parseFloat(quote.total_material_cost || 0),
subtotal: parseFloat(quote.subtotal || 0),
vat_amount: parseFloat(quote.vat_amount || 0),
total_incl_vat: parseFloat(quote.total_incl_vat || 0)
}
};
// Generate PDF using Python script (fast!)
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Write data to temp JSON file
const tempJsonPath = `/tmp/quote_data_${quoteId}_${Date.now()}.json`;
const tempPdfPath = `/tmp/quote_${quoteId}_${Date.now()}.pdf`;
fs.writeFileSync(tempJsonPath, JSON.stringify(pdfData, null, 2));
// Run Python PDF generator
const pythonProcess = spawn('python3', [
path.join(__dirname, 'backend/pdf_generator.py'),
tempJsonPath,
tempPdfPath
]);
let pythonOutput = '';
let pythonError = '';
pythonProcess.stdout.on('data', (data) => {
pythonOutput += data.toString();
});
pythonProcess.stderr.on('data', (data) => {
pythonError += data.toString();
});
pythonProcess.on('close', async (code) => {
try {
// Clean up temp JSON file
fs.unlinkSync(tempJsonPath);
if (code === 0 && fs.existsSync(tempPdfPath)) {
// Read PDF file and store in database
const pdfBuffer = fs.readFileSync(tempPdfPath);
// Update database with PDF
await databaseService.query(
'UPDATE generated_quotes SET pdf_data = ?, quote_format = ? WHERE id = ?',
[pdfBuffer, 'pdf', quoteId]
);
// Clean up temp PDF file
fs.unlinkSync(tempPdfPath);
console.log(`✅ PDF generated successfully for quote ${quoteId}`);
console.log(`📊 Python output: ${pythonOutput}`);
} else {
console.error(`❌ PDF generation failed for quote ${quoteId}, exit code: ${code}`);
console.error(`📊 Python output: ${pythonOutput}`);
console.error(`📊 Python error: ${pythonError}`);
console.error(`📊 Temp PDF exists: ${fs.existsSync(tempPdfPath)}`);
}
} catch (error) {
console.error('Error in PDF generation cleanup:', error);
}
});
// Send immediate success response (Python is fast enough)
res.json({
success: true,
message: 'PDF genereret succesfuldt!',
quoteId: quoteId
});
} catch (error) {
console.error('Error generating PDF:', error);
res.status(500).json({
success: false,
error: 'Fejl ved PDF generering: ' + error.message
});
}
});
// Check PDF generation status for a quote
app.get('/api/quotes/:quoteId/pdf-status', async (req, res) => {
try {
const { quoteId } = req.params;
const [quote] = await databaseService.query(
'SELECT quote_format, pdf_data, notes FROM generated_quotes WHERE id = ?',
[quoteId]
);
if (!quote) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
const hasPDF = quote.quote_format === 'pdf' && quote.pdf_data !== null;
const hasError = quote.notes && quote.notes.includes('PDF generation failed');
res.json({
success: true,
data: {
hasPDF: hasPDF,
status: hasError ? 'failed' : (hasPDF ? 'completed' : 'pending'),
format: quote.quote_format,
error: hasError ? quote.notes : null
}
});
} catch (error) {
console.error('Error checking PDF status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tjek af PDF status'
});
}
});
// Create new material price
app.post('/api/material-price', async (req, res) => {
try {
const { name, unit, unit_price, supplier_name, material_category } = req.body;
// Create a document entry first
const docResult = await databaseService.query(
'INSERT INTO ocr_documents (filename, supplier_name, document_type, processing_status) VALUES (?, ?, ?, ?)',
['Manual Entry', supplier_name || 'Manual', 'price_list', 'processed']
);
// Then create the material entry
const result = await databaseService.query(
'INSERT INTO ocr_materials (ocr_document_id, name, unit, unit_price, total_price, material_category, quantity, confidence) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[docResult.insertId, name, unit, unit_price, unit_price, material_category, 1, 1.0]
);
res.json({
success: true,
message: 'Material price created successfully',
id: result.insertId
});
} catch (error) {
console.error('Error creating material price:', error);
res.status(500).json({ success: false, message: 'Error creating material price' });
}
});
// Update material price
app.put('/api/material-price/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, unit, unit_price, material_category } = req.body;
const result = await databaseService.query(
'UPDATE ocr_materials SET name = ?, unit = ?, unit_price = ?, total_price = ?, material_category = ? WHERE id = ?',
[name, unit, unit_price, unit_price, material_category, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Material price not found' });
}
res.json({ success: true, message: 'Material price updated successfully' });
} catch (error) {
console.error('Error updating material price:', error);
res.status(500).json({ success: false, message: 'Error updating material price' });
}
});
// Delete material price
app.delete('/api/material-price/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await databaseService.query(
'DELETE FROM ocr_materials WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Material price not found' });
}
res.json({ success: true, message: 'Material price deleted successfully' });
} catch (error) {
console.error('Error deleting material price:', error);
res.status(500).json({ success: false, message: 'Error deleting material price' });
}
});
// Save quote to database as statistical data
app.post('/api/customer-projects/:projectId/save-quote', async (req, res) => {
try {
const { projectId } = req.params;
const {
quote_text,
total_amount,
material_cost,
labor_cost,
labor_hours,
materials_used,
calculation_method,
delivered_to_customer,
customer_response,
notes
} = req.body;
// Insert quote into database using existing table structure
const result = await databaseService.query(`
INSERT INTO project_quotes (
project_type, project_area, customer_name, customer_address,
material_cost, labor_cost, overhead_cost, total_excl_vat,
vat_amount, total_incl_vat, currency, labor_rate,
notes, metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
'tag', // project_type
102.5, // project_area
'Jens Hansen', // customer_name
'Hornumvej 15, 7400 Herning', // customer_address
material_cost, // material_cost
labor_cost, // labor_cost
0, // overhead_cost
total_amount / 1.25, // total_excl_vat (removing VAT)
total_amount * 0.2, // vat_amount (25% VAT)
total_amount, // total_incl_vat
'DKK', // currency
500, // labor_rate
notes, // notes
JSON.stringify({
quote_text,
labor_hours,
materials_used,
calculation_method,
delivered_to_customer,
customer_response,
project_id: projectId
}) // metadata
]);
res.json({
success: true,
quoteId: result.insertId,
message: 'Tilbud gemt som statistik data'
});
} catch (error) {
console.error('Error saving quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gem af tilbud'
});
}
});
// Get quote calculation flow
app.get('/api/customer-projects/:projectId/quotes/:quoteId/flow', async (req, res) => {
try {
const { projectId, quoteId } = req.params;
// Get quote details
const quote = await databaseService.query(
'SELECT * FROM project_quotes WHERE id = ? AND project_id = ?',
[quoteId, projectId]
);
if (quote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
const quoteData = quote[0];
// Get related calculation data
let calculation = [];
try {
calculation = await databaseService.query(
'SELECT * FROM saved_calculations WHERE project_id = ? AND created_at <= ? ORDER BY created_at DESC LIMIT 1',
[projectId, quoteData.created_at],
{ silenceErrors: true }
);
} catch (error) {
// Table doesn't exist, use empty array
}
// Get materials at time of quote
const materials = JSON.parse(quoteData.materials_used || '[]');
// Get project geometry
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
res.json({
success: true,
flow: {
quote: quoteData,
calculation_used: calculation[0] || null,
materials_breakdown: materials,
geometry_used: geometry[0] || null,
calculation_steps: {
step1_geometry: {
description: 'Tag dimensioner og kompleksitet',
data: geometry[0],
impact_on_price: 'Areal og hældning påvirker arbejdstid og materialer'
},
step2_materials: {
description: 'Materialevalg og priser',
data: materials,
impact_on_price: 'Direkte omkostning + transport og spild'
},
step3_labor: {
description: 'Arbejdstimer og mandskab',
data: calculation[0],
impact_on_price: 'Timer × timeløn × kompleksitetsfaktor'
},
step4_final_calculation: {
description: 'Samlet beregning',
material_cost: quoteData.material_cost,
labor_cost: quoteData.labor_cost,
total: quoteData.total_amount,
impact_on_price: 'Materialer + arbejde + overhead'
}
}
}
});
} catch (error) {
console.error('Error getting quote flow:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tilbuds flow'
});
}
});
// Get delivered quotes to customer
app.get('/api/customer-projects/:projectId/delivered-quotes', async (req, res) => {
try {
const { projectId } = req.params;
let quotes = [];
try {
quotes = await databaseService.query(
'SELECT * FROM project_quotes WHERE project_id = ? AND delivered_to_customer = 1 ORDER BY created_at DESC',
[projectId],
{ silenceErrors: true }
);
} catch (error) {
// Table/column doesn't exist, use empty array
}
res.json({
success: true,
delivered_quotes: quotes,
count: quotes.length
});
} catch (error) {
console.error('Error getting delivered quotes:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af afleverede tilbud'
});
}
});
// Get quote statistics for better future estimates
app.get('/api/statistics/quotes', async (req, res) => {
try {
const { project_type, area_min, area_max } = req.query;
let whereClause = 'WHERE total_incl_vat IS NOT NULL';
let params = [];
if (project_type) {
whereClause += ' AND project_type = ?';
params.push(project_type);
}
if (area_min && area_max) {
whereClause += ' AND project_area BETWEEN ? AND ?';
params.push(area_min, area_max);
}
const statistics = await databaseService.query(`
SELECT
COUNT(*) as total_quotes,
AVG(total_incl_vat) as avg_total_amount,
MIN(total_incl_vat) as min_total_amount,
MAX(total_incl_vat) as max_total_amount,
AVG(material_cost) as avg_material_cost,
AVG(labor_cost) as avg_labor_cost,
AVG(project_area) as avg_area,
project_type
FROM project_quotes
${whereClause}
GROUP BY project_type
`, params);
res.json({
success: true,
statistics: statistics
});
} catch (error) {
console.error('Error getting quote statistics:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tilbuds statistik'
});
}
});
// Get all quotes for a project with detailed view
app.get('/api/customer-projects/:projectId/quotes', async (req, res) => {
try {
const { projectId } = req.params;
// Get all quotes for the project from metadata
const quotes = await databaseService.query(`
SELECT id, project_type, customer_name, total_incl_vat, material_cost,
labor_cost, created_at, notes, metadata
FROM project_quotes
WHERE JSON_EXTRACT(metadata, '$.project_id') = ?
ORDER BY created_at DESC
`, [projectId]);
// Parse metadata for each quote
const quotesWithDetails = quotes.map(quote => {
let metadata = {};
try {
metadata = JSON.parse(quote.metadata || '{}');
} catch (e) {
metadata = {};
}
return {
...quote,
quote_text: metadata.quote_text || 'Ikke tilgængelig',
labor_hours: metadata.labor_hours || 0,
materials_used: metadata.materials_used || [],
calculation_method: metadata.calculation_method || 'Ukendt',
delivered_to_customer: metadata.delivered_to_customer || false,
customer_response: metadata.customer_response || 'Afventer svar'
};
});
res.json({
success: true,
quotes: quotesWithDetails,
project_id: projectId,
count: quotesWithDetails.length
});
} catch (error) {
console.error('Error getting project quotes:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt tilbud'
});
}
});
// Ordrestyring Integration Endpoints
// Test Ordrestyring API connection
app.get('/api/ordrestyring/test', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const testResult = await ordrestyringService.testConnection();
res.json({
success: true,
message: 'Ordrestyring API forbindelse succesfuld',
data: testResult
});
} catch (error) {
console.error('Ordrestyring test error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved test af Ordrestyring API: ' + error.message
});
}
});
// Import all cases from Ordrestyring
app.post('/api/ordrestyring/import', async (req, res) => {
try {
if (!ordrestyringService || !databaseService) {
return res.status(503).json({
success: false,
error: 'Services ikke initialiseret'
});
}
console.log('Starting Ordrestyring import...');
const importResult = await ordrestyringService.importCasesToDatabase(databaseService);
res.json({
success: true,
message: 'Import fuldført succesfuldt',
data: importResult
});
} catch (error) {
console.error('Import error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved import af data: ' + error.message
});
}
});
// Get import statistics
app.get('/api/ordrestyring/statistics', async (req, res) => {
try {
if (!ordrestyringService || !databaseService) {
return res.status(503).json({
success: false,
error: 'Services ikke initialiseret'
});
}
const statistics = await ordrestyringService.generateStatistics(databaseService);
res.json({
success: true,
data: statistics
});
} catch (error) {
console.error('Statistics error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved generering af statistikker: ' + error.message
});
}
});
// Get imported quotes with pagination
app.get('/api/ordrestyring/quotes', async (req, res) => {
try {
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke initialiseret'
});
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const search = req.query.search || '';
// Build where clause for search
let whereClause = "WHERE source = 'ordrestyring'";
const params = [];
if (search) {
whereClause += " AND (customer_name LIKE ? OR project_name LIKE ? OR external_case_number LIKE ?)";
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
// Get total count
const [countResult] = await databaseService.query(
`SELECT COUNT(*) as total FROM imported_quotes ${whereClause}`,
params
);
// Get quotes
const quotes = await databaseService.query(
`SELECT * FROM imported_quotes ${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
res.json({
success: true,
data: {
quotes: quotes,
pagination: {
current_page: page,
per_page: limit,
total: countResult.total,
total_pages: Math.ceil(countResult.total / limit)
}
}
});
} catch (error) {
console.error('Error getting imported quotes:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af importerede tilbud'
});
}
});
// Get detailed case from Ordrestyring by ID
app.get('/api/ordrestyring/case/:caseId', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const { caseId } = req.params;
const caseData = await ordrestyringService.getCaseById(caseId);
if (!caseData) {
return res.status(404).json({
success: false,
error: 'Case ikke fundet'
});
}
res.json({
success: true,
data: caseData
});
} catch (error) {
console.error('Error getting case details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af case detaljer: ' + error.message
});
}
});
// =====================================
// Vendor Data Import Endpoints (Bygma)
// =====================================
// Import Bygma ESG data
app.post('/api/vendor/bygma/import', async (req, res) => {
try {
if (!databaseService) {
return res.status(500).json({
success: false,
error: 'Database service ikke initialiseret'
});
}
// Expected file path from frontend upload
const { filePath, fileName } = req.body;
if (!filePath) {
return res.status(400).json({
success: false,
error: 'Fil sti mangler'
});
}
const BygmaImportService = require('./backend/src/services/bygmaImportService');
const bygmaImporter = new BygmaImportService(databaseService);
const result = await bygmaImporter.importESGData(filePath, fileName);
res.json({
success: true,
message: 'Bygma data importeret succesfuldt',
...result
});
} catch (error) {
console.error('Bygma import error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved import af Bygma data: ' + error.message
});
}
});
// Get vendor import history
app.get('/api/vendor/import-history', async (req, res) => {
try {
if (!databaseService) {
return res.status(500).json({
success: false,
error: 'Database service ikke initialiseret'
});
}
const { vendorCode, limit = 20 } = req.query;
let query = `
SELECT
vil.id,
v.vendor_name,
v.vendor_code,
vil.source_file,
vil.records_processed,
vil.records_imported,
vil.records_updated,
vil.records_failed,
vil.data_period_start,
vil.data_period_end,
vil.import_status,
vil.error_message,
vil.imported_by,
vil.import_started_at,
vil.import_completed_at
FROM vendor_import_log vil
INNER JOIN vendors v ON vil.vendor_id = v.id
`;
const params = [];
if (vendorCode) {
query += ' WHERE v.vendor_code = ?';
params.push(vendorCode);
}
query += ' ORDER BY vil.import_started_at DESC LIMIT ?';
params.push(parseInt(limit));
const imports = await databaseService.query(query, params);
res.json({
success: true,
imports
});
} catch (error) {
console.error('Import history error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af import historik: ' + error.message
});
}
});
// Get vendor price statistics
app.get('/api/vendor/price-statistics', async (req, res) => {
try {
if (!databaseService) {
return res.status(500).json({
success: false,
error: 'Database service ikke initialiseret'
});
}
const { vendorCode = 'BYGMA', productCode, dateFrom, dateTo } = req.query;
// Get vendor ID
const [vendor] = await databaseService.query(
'SELECT id FROM vendors WHERE vendor_code = ?',
[vendorCode]
);
if (!vendor) {
return res.status(404).json({
success: false,
error: `Leverandør ${vendorCode} ikke fundet`
});
}
// Build statistics query
let query = `
SELECT
vph.vendor_product_code,
vph.product_name,
vph.unit,
COUNT(vpr.id) as purchase_count,
SUM(vpr.quantity) as total_quantity,
AVG(vpr.unit_price_dkk) as avg_price,
MIN(vpr.unit_price_dkk) as min_price,
MAX(vpr.unit_price_dkk) as max_price,
MIN(vpr.invoice_date) as first_purchase,
MAX(vpr.invoice_date) as last_purchase,
SUM(vpr.total_price_dkk) as total_spent
FROM vendor_products_history vph
INNER JOIN vendor_price_history vpr ON vph.id = vpr.product_history_id
WHERE vph.vendor_id = ?
`;
const params = [vendor.id];
if (productCode) {
query += ' AND vph.vendor_product_code = ?';
params.push(productCode);
}
if (dateFrom) {
query += ' AND vpr.invoice_date >= ?';
params.push(dateFrom);
}
if (dateTo) {
query += ' AND vpr.invoice_date <= ?';
params.push(dateTo);
}
query += ' GROUP BY vph.vendor_product_code, vph.product_name, vph.unit';
query += ' ORDER BY total_spent DESC LIMIT 100';
const statistics = await databaseService.query(query, params);
// Get overall summary
const [summary] = await databaseService.query(`
SELECT
COUNT(DISTINCT vph.vendor_product_code) as unique_products,
COUNT(vpr.id) as total_purchases,
SUM(vpr.total_price_dkk) as total_spent,
AVG(vpr.unit_price_dkk) as avg_unit_price,
MIN(vpr.invoice_date) as period_start,
MAX(vpr.invoice_date) as period_end
FROM vendor_products_history vph
INNER JOIN vendor_price_history vpr ON vph.id = vpr.product_history_id
WHERE vph.vendor_id = ?
${dateFrom ? 'AND vpr.invoice_date >= ?' : ''}
${dateTo ? 'AND vpr.invoice_date <= ?' : ''}
`, params);
res.json({
success: true,
vendor: {
code: vendorCode,
name: 'Bygma A/S'
},
summary: summary || {},
products: statistics
});
} catch (error) {
console.error('Price statistics error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af pris statistik: ' + error.message
});
}
});
// Handle React Router - serve index.html for all non-API routes (MUST BE LAST)
app.get('*', (req, res) => {
const indexPath = path.join(__dirname, 'frontend', 'build', 'index.html');
console.log('📄 Serving index.html from:', indexPath);
res.sendFile(indexPath);
});
// Start server
const startServer = async () => {
const backendReady = await initializeBackend();
app.listen(PORT, () => {
console.log(`Unified server running on port ${PORT}`);
console.log('Frontend: Serving React app');
console.log('Backend: API available at /api/*');
console.log(`Backend services: ${backendReady ? 'Ready' : 'Failed'}`);
});
};
startServer();