- 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.
5252 lines
157 KiB
JavaScript
5252 lines
157 KiB
JavaScript
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();
|