Files
tilbudgivern/backend/unified-server.js
T
Alexandalexpolo1 37e69561bc
CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
feat(ordrestyring): add sync status menu (#33)
* [verified] feat(ordrestyring): add sync status menu

* [verified] fix(ordrestyring): migrate legacy sync consumers

* [verified] fix(ordrestyring): preserve sync status history

* [verified] fix(ordrestyring): tolerate optional sync status data

* [verified] fix(ordrestyring): contain customer sync cleanup

* [verified] fix(ordrestyring): keep cache sync non-authoritative

* [verified] fix(ordrestyring): project the next scheduler tick

* [verified] fix(ordrestyring): track automatic sync schedule

* [verified] fix(ordrestyring): honor configured API origin

* [verified] fix(ordrestyring): show startup scheduler tick

* [verified] fix(ordrestyring): contain core sync cleanup

* [verified] fix(ordrestyring): exclude cache from sync totals

---------

Co-authored-by: alexpolo1 <[email protected]>
2026-09-29 10:25:45 +02:00

11357 lines
353 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const path = require('path');
const cors = require('cors');
const compression = require('compression');
const fs = require('fs');
const http = require('http');
const os = require('os');
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const axios = require('axios');
const { Parser } = require('expr-eval');
const { validateRidgeHeight } = require('./src/utils/geometryValidation');
const {
CANONICAL_ROOF_TYPE_OPTIONS,
estimateCanonicalRoofLabor,
requireRoofTypeEstimate
} = require('./src/domain/roofTypeContract');
const QuoteRealismService = require('./src/services/quoteRealismService');
const userService = require('./src/services/userService');
// const HistoricalQuoteCalculator = require('./historical-quote-calculator.js'); // Temporarily disabled
// Load environment variables FIRST
require('dotenv').config({ path: path.join(__dirname, '.env') });
const {
verifyToken,
requireAdmin,
requireConfiguredOperator: requireConfiguredProjectOperator
} = require('./src/middleware/auth');
const { projectOrdrestyringSyncStatus } = require('./src/services/ordrestyringSyncStatusProjection');
// ========================================
// STRUCTURED LOGGING SYSTEM
// ========================================
const logError = (context, error, additionalInfo = {}) => {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level: 'ERROR',
context,
message: error.message || error,
stack: error.stack,
code: error.code,
errno: error.errno,
sqlState: error.sqlState,
sqlMessage: error.sqlMessage,
...additionalInfo
};
// Log to console with visual structure
console.error(`❌ [${timestamp}] ${context}:`);
console.error(` Message: ${logEntry.message}`);
if (logEntry.code) console.error(` Code: ${logEntry.code}`);
if (logEntry.sqlMessage) console.error(` SQL: ${logEntry.sqlMessage}`);
if (Object.keys(additionalInfo).length > 0) {
console.error(` Extra:`, JSON.stringify(additionalInfo, null, 2));
}
return logEntry;
};
const logInfo = (context, message, additionalInfo = {}) => {
const timestamp = new Date().toISOString();
console.log(`ℹ️ [${timestamp}] ${context}: ${message}`);
if (Object.keys(additionalInfo).length > 0) {
console.log(` Details:`, JSON.stringify(additionalInfo, null, 2));
}
};
const logWarning = (context, message, additionalInfo = {}) => {
const timestamp = new Date().toISOString();
console.warn(`⚠️ [${timestamp}] ${context}: ${message}`);
if (Object.keys(additionalInfo).length > 0) {
console.warn(` Details:`, JSON.stringify(additionalInfo, null, 2));
}
};
// Store primary database configuration before loading Ordrestyring config
const primaryDbConfig = {
DB_HOST: process.env.DB_HOST || '127.0.0.1',
DB_USER: process.env.DB_USER || 'tilbuduser',
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME || 'tilbudgivern',
DB_PORT: process.env.DB_PORT || '3306'
};
// Load Ordrestyring database configuration if available
const ordrestyringEnvPath = path.join(__dirname, '.env.ordrestyring');
let ordrestyringConfig = {};
if (fs.existsSync(ordrestyringEnvPath)) {
// Store Ordrestyring config separately
const ordrestyringEnv = require('fs').readFileSync(ordrestyringEnvPath, 'utf8');
ordrestyringEnv.split('\n').forEach(line => {
if (line.includes('=') && !line.startsWith('#')) {
const [key, value] = line.split('=');
if (key && value) {
ordrestyringConfig[key.trim()] = value.trim();
}
}
});
// Set Ordrestyring-specific environment variables
if (ordrestyringConfig.ORDRESTYRING_TOKEN) {
process.env.ORDRESTYRING_TOKEN = ordrestyringConfig.ORDRESTYRING_TOKEN;
}
console.log('✅ Loaded Ordrestyring configuration');
}
// Restore primary database configuration
Object.keys(primaryDbConfig).forEach(key => {
process.env[key] = primaryDbConfig[key];
});
console.log('🗄️ Using primary database:', process.env.DB_NAME, 'on', process.env.DB_HOST);
const getPositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const getBoundedInt = (value, fallback, min, max) => {
const parsed = getPositiveInt(value, fallback);
return Math.min(max, Math.max(min, parsed));
};
const hardwareThreads = Math.max(
1,
typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length
);
const mainDbConnectionLimit = getPositiveInt(process.env.DB_CONNECTION_LIMIT, 50);
const healthDbConnectionLimit = getPositiveInt(process.env.HEALTH_DB_CONNECTION_LIMIT, 3);
const compressionLevel = getBoundedInt(process.env.HTTP_COMPRESSION_LEVEL, 6, 1, 9);
const compressionThresholdBytes = getPositiveInt(process.env.HTTP_COMPRESSION_THRESHOLD_BYTES, 1024);
const staticAssetMaxAgeSeconds = getPositiveInt(process.env.STATIC_ASSET_MAX_AGE_SECONDS, 31536000);
const staticFileMaxAgeSeconds = getPositiveInt(process.env.STATIC_FILE_MAX_AGE_SECONDS, 3600);
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const PORT = process.env.PORT || 4032;
// Database connection setup
const mysql = require('mysql2/promise');
const db = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
user: process.env.DB_USER || 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'tilbudgivern', // Use configured database
waitForConnections: true,
connectionLimit: mainDbConnectionLimit,
queueLimit: 0
});
// SEPARATE LIGHTWEIGHT POOL FOR HEALTH/METRICS (to avoid main pool starvation)
const healthPool = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
user: process.env.DB_USER || 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'tilbudgivern',
waitForConnections: true,
connectionLimit: healthDbConnectionLimit,
queueLimit: 0
});
// Import logger
const logger = require('./src/utils/logger');
const { bootstrapServiceContainer } = require('./src/services/bootstrapServiceContainer');
const { calculateQuoteEconomics } = require('./src/services/quoteEconomicsService');
const { sendPdfDownload } = require('./src/services/pdfResponseService');
const RoofGeometryService = require('./src/services/roofGeometryService');
const SmartPackageWorkspaceService = require('./src/services/smartPackageWorkspaceService');
const { RoofQuoteSnapshotService } = require('./src/services/roofQuoteSnapshotService');
// WebSocket connection handling
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id);
socket.on('join_project', (projectId) => {
socket.join(`project_${projectId}`);
console.log(`📊 Client joined project room: project_${projectId}`);
});
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id);
});
});
// Helper function to broadcast updates
function broadcastProjectUpdate(projectId, updateType, data) {
io.to(`project_${projectId}`).emit('project_update', {
type: updateType,
projectId: projectId,
data: data,
timestamp: new Date().toISOString()
});
}
// Middleware
const requestLogger = require('./src/middleware/requestLogger');
const {
httpErrorLogger,
sessionErrorLogger,
authErrorLogger,
responseErrorLogger
} = require('./src/middleware/httpErrorLogger');
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(compression({
threshold: compressionThresholdBytes,
level: compressionLevel
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Session error logger (check before request processing)
app.use('/api', sessionErrorLogger);
// Request logging middleware (log all API calls)
app.use('/api', requestLogger);
// Response error logger (capture error responses) - DISABLED due to hanging issue
// app.use('/api', responseErrorLogger);
// Material quantity calculation with formulas
async function calculateMaterialQuantities(projectId, geometry) {
try {
console.log('🧮 Calculating material quantities for project:', projectId, 'geometry:', geometry);
// Get all material formulas
const formulas = await databaseService.query(
'SELECT * FROM material_formulas WHERE qty_formula IS NOT NULL'
);
const results = [];
for (const formula of formulas) {
try {
// Build evaluation context (support both GEOM.xxx and direct vars)
const GEOM = {
area_m2: Number(geometry.area_m2) || 0,
vindskede_lbm: Number(geometry.vindskede_lbm) || 0
};
const vars = { GEOM, ...GEOM };
// Evaluate formula using a safe math parser
let calculatedValue = 0;
try {
const parser = new Parser({});
const expr = parser.parse(String(formula.qty_formula || '0'));
calculatedValue = Number(expr.evaluate(vars)) || 0;
} catch (e) {
console.error(`❌ Formula parse/eval error for ${formula.material_sku}:`, e.message);
calculatedValue = 0;
}
const quantity = Math.ceil(calculatedValue * formula.unit_per_formula * formula.waste_factor);
if (quantity > 0) {
// Check if material exists in project
const existingMaterial = await databaseService.query(
'SELECT id, quantity FROM project_materials WHERE project_id = ? AND material_name = ?',
[projectId, formula.material_sku]
);
if (existingMaterial.length > 0) {
// Update existing material quantity
await databaseService.query(
'UPDATE project_materials SET quantity = ?, updated_at = NOW() WHERE id = ?',
[quantity, existingMaterial[0].id]
);
console.log(`📦 Updated ${formula.material_sku}: ${existingMaterial[0].quantity} → ${quantity}`);
} else {
// Add new material to project
await databaseService.query(
'INSERT INTO project_materials (project_id, material_name, varenr, quantity, material_category, unit, unit_price, total_price, supplier, material_source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())',
[projectId, formula.material_sku, null, quantity, 'Package Material', 'stk', 0, 0, 'Package System', 'package']
);
console.log(`📦 Added ${formula.material_sku}: ${quantity} units`);
}
results.push({
sku: formula.material_sku,
formula: formula.qty_formula,
calculated: calculatedValue,
quantity: quantity,
formula_type: formula.formula_type
});
}
} catch (evalError) {
console.error(`❌ Formula evaluation error for ${formula.material_sku}:`, evalError.message);
}
}
return results;
} catch (error) {
console.error('❌ Material calculation error:', error);
throw error;
}
}
// 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
// Note: ordrestyringService deprecated - use GraphQL client instead
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, realDataProjectSuggestionService, enhancedOrderDataService, materialPackageService, smartPackageManagementService, planningService, ordrestyringSyncService, quoteRealismService, roofQuoteSnapshotService;
let PriceImportServiceClass, BygmaPrisbogImportServiceClass, PackageServiceClass, ProjectQuoteGenerationServiceClass, RoofGeometryServiceClass, BygmaImportServiceClass, LocalOrdrestyringAnalyzerClass;
let ordrestyringService, graphqlClient;
const initializeBackend = async () => {
try {
// Centralized service bootstrap container
const services = await bootstrapServiceContainer({
logger,
db,
healthPool
});
databaseService = services.databaseService;
const canonicalRoofGeometryService = new RoofGeometryService(databaseService);
const canonicalWorkspaceService = new SmartPackageWorkspaceService(databaseService);
roofQuoteSnapshotService = new RoofQuoteSnapshotService({
databaseService,
roofGeometryService: canonicalRoofGeometryService,
workspaceService: canonicalWorkspaceService
});
quoteRealismService = new QuoteRealismService(databaseService, roofQuoteSnapshotService);
openaiService = services.openaiService;
webPriceService = services.webPriceService;
dynamicImportService = services.dynamicImportService;
orderStatusService = services.orderStatusService;
quoteTemplateService = services.quoteTemplateService;
pdfGenerationService = services.pdfGenerationService;
realDataProjectSuggestionService = services.realDataProjectSuggestionService;
enhancedOrderDataService = services.enhancedOrderDataService;
materialPackageService = services.materialPackageService;
smartPackageManagementService = services.smartPackageManagementService;
planningService = services.planningService;
ordrestyringSyncService = services.ordrestyringSyncService;
PriceImportServiceClass = services.PriceImportService;
BygmaPrisbogImportServiceClass = services.BygmaPrisbogImportService;
PackageServiceClass = services.PackageService;
ProjectQuoteGenerationServiceClass = services.ProjectQuoteGenerationService;
RoofGeometryServiceClass = services.RoofGeometryService;
BygmaImportServiceClass = services.BygmaImportService;
LocalOrdrestyringAnalyzerClass = services.LocalOrdrestyringAnalyzer;
ordrestyringService = services.ordrestyringService;
graphqlClient = services.graphqlClient;
// Make services available globally and to routes
app.locals.services = services;
app.locals.databaseService = databaseService;
app.locals.roofQuoteSnapshotService = roofQuoteSnapshotService;
app.locals.db = db;
app.locals.healthPool = healthPool;
global.databaseService = databaseService;
global.roofQuoteSnapshotService = roofQuoteSnapshotService;
global.openaiService = openaiService;
global.smartPackageManagementService = smartPackageManagementService;
await require('./src/services/aiValidationJobService').initialize(databaseService);
return true;
} catch (error) {
logger.error('Failed to initialize backend:', error);
return false;
}
};
// API Routes - simplified versions of main endpoints
const PROTECTED_OPERATOR_PREFIXES = [
'/api/customer-projects',
'/api/projects',
'/api/project-materials',
'/api/material-packages',
'/api/quotes'
];
const isOrdinaryAuthenticatedStatus = req => {
if (req.method !== 'GET') return false;
const pathWithoutQuery = String(req.originalUrl || '').split('?')[0];
return pathWithoutQuery === '/api/customer-projects/material-price-status'
|| /^\/api\/customer-projects\/projects\/\d+\/realism-analysis$/.test(pathWithoutQuery);
};
app.use(PROTECTED_OPERATOR_PREFIXES, verifyToken, (req, res, next) => {
if (isOrdinaryAuthenticatedStatus(req)) return next();
res.locals.operatorDeniedMessage = 'Ingen adgang til projektets tilbudsdata';
return requireConfiguredProjectOperator(req, res, next);
});
// Dashboard metrics endpoint (uses separate pool - promise-based)
app.get('/api/dashboard/metrics', async (req, res) => {
try {
const conn = await healthPool.getConnection();
const [projects] = await conn.query('SELECT COUNT(*) as count FROM customer_projects');
const [quotes] = await conn.query('SELECT COUNT(*) as count FROM customer_projects WHERE project_status IN ("review_pending", "ready_for_ordrestyring", "sent_to_ordrestyring", "quote_generated", "sent")');
const [materials] = await conn.query('SELECT COUNT(*) as count FROM smart_package_materials');
conn.release();
res.json({
success: true,
metrics: {
totalProjects: projects[0]?.count || 0,
totalQuotes: quotes[0]?.count || 0,
totalMaterials: materials[0]?.count || 0,
activeSessions: Math.floor(Math.random() * 10) + 1,
apiCalls: Math.floor(Math.random() * 500),
dbQueries: Math.floor(Math.random() * 1000)
},
timestamp: new Date().toISOString()
});
} catch (error) {
res.json({
success: true,
metrics: {
totalProjects: 0,
totalQuotes: 0,
totalMaterials: 0,
activeSessions: 0,
apiCalls: 0,
dbQueries: 0
},
error: error.message
});
}
});
// ===================================
// ANALYTICS API - GraphQL INTEGRATION
// ===================================
// Analytics endpoints moved to backend/src/routes/analytics.js
// ===================================
// CUSTOMER SEARCH - Ordrestyring Database
// ===================================
// Search customers from local Ordrestyring sync database
app.get('/api/customers/search', async (req, res) => {
try {
const searchQuery = req.query.q || '';
if (!searchQuery || searchQuery.length < 2) {
return res.status(400).json({
success: false,
error: 'Søgeforespørgsel skal være mindst 2 tegn'
});
}
console.log(`🔍 Searching customers for: "${searchQuery}"`);
// Connect to ordrestyring_local database
const ordrestyringDb = await mysql.createConnection({
host: '127.0.0.1',
user: 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: 'ordrestyring_local',
charset: 'utf8mb4'
});
try {
// Search by customer number, name, address, city, postal code, email, or phone
const [customers] = await ordrestyringDb.execute(`
SELECT
customer_number,
customer_name,
customer_address,
customer_city,
customer_postalcode,
customer_email,
customer_telephone,
customer_mobile,
cvr,
total_revenue,
case_count,
last_case_date
FROM ordrestyring_debtors
WHERE
customer_number LIKE ? OR
customer_name LIKE ? OR
customer_address LIKE ? OR
customer_city LIKE ? OR
customer_postalcode LIKE ? OR
CONCAT_WS(' ', customer_address, customer_postalcode, customer_city) LIKE ? OR
customer_email LIKE ? OR
customer_telephone LIKE ? OR
customer_mobile LIKE ?
ORDER BY
CASE
WHEN customer_name LIKE ? THEN 1
WHEN customer_number LIKE ? THEN 2
WHEN customer_address LIKE ? THEN 3
ELSE 4
END,
total_revenue DESC,
customer_name ASC
LIMIT 50
`, [
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`%${searchQuery}%`,
`${searchQuery}%`, // Prioritize starts-with for name
`${searchQuery}%`, // Prioritize starts-with for number
`${searchQuery}%` // Then prioritize starts-with for address
]);
// Format results for frontend
const formattedCustomers = customers.map(customer => ({
customerNumber: customer.customer_number,
name: customer.customer_name,
address: customer.customer_address,
city: customer.customer_city,
postalCode: customer.customer_postalcode,
email: customer.customer_email,
phone: customer.customer_telephone || customer.customer_mobile,
mobile: customer.customer_mobile,
cvr: customer.cvr,
// Additional analytics
totalRevenue: customer.total_revenue,
caseCount: customer.case_count,
lastCaseDate: customer.last_case_date,
// Display format
displayText: `${customer.customer_number} - ${customer.customer_name}${customer.customer_city ? ` (${customer.customer_city})` : ''}`
}));
res.json({
success: true,
count: formattedCustomers.length,
customers: formattedCustomers,
query: searchQuery
});
console.log(`✅ Found ${formattedCustomers.length} customers matching "${searchQuery}"`);
} finally {
await ordrestyringDb.end();
}
} catch (error) {
console.error('Customer search error:', error.message);
res.status(500).json({
success: false,
error: 'Fejl ved kunde-søgning',
details: error.message
});
}
});
// Get single customer by customer number
app.get('/api/customers/:customerNumber', async (req, res) => {
try {
const { customerNumber } = req.params;
console.log(`📋 Fetching customer: ${customerNumber}`);
const ordrestyringDb = await mysql.createConnection({
host: '127.0.0.1',
user: 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: 'ordrestyring_local',
charset: 'utf8mb4'
});
try {
const [customers] = await ordrestyringDb.execute(`
SELECT
customer_number,
customer_name,
customer_address,
customer_city,
customer_postalcode,
customer_email,
customer_telephone,
customer_mobile,
cvr,
category_id,
payment_terms_id,
total_revenue,
avg_case_value,
case_count,
last_case_date,
created_at,
updated_at,
last_sync
FROM ordrestyring_debtors
WHERE customer_number = ?
`, [customerNumber]);
if (customers.length === 0) {
return res.status(404).json({
success: false,
error: 'Kunde ikke fundet'
});
}
const customer = customers[0];
res.json({
success: true,
customer: {
customerNumber: customer.customer_number,
name: customer.customer_name,
address: customer.customer_address,
city: customer.customer_city,
postalCode: customer.customer_postalcode,
email: customer.customer_email,
phone: customer.customer_telephone,
mobile: customer.customer_mobile,
cvr: customer.cvr,
categoryId: customer.category_id,
paymentTermsId: customer.payment_terms_id,
// Analytics
totalRevenue: customer.total_revenue,
avgCaseValue: customer.avg_case_value,
caseCount: customer.case_count,
lastCaseDate: customer.last_case_date,
// Metadata
createdAt: customer.created_at,
updatedAt: customer.updated_at,
lastSync: customer.last_sync
}
});
console.log(`✅ Customer fetched: ${customer.customer_name}`);
} finally {
await ordrestyringDb.end();
}
} catch (error) {
console.error('Customer fetch error:', error.message);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af kunde',
details: error.message
});
}
});
// ===================================
// CARPENTER TASKS - DEPRECATED
// ===================================
// Carpenter tasks are now generated from installation manuals via AI analysis
// See /api/pricing/scrape-manual endpoint and installation_manuals table
// All carpenter task data comes from montagevejledninger scraped from Bygma
app.use('/api/carpenter/*', async (req, res) => {
res.status(410).json({
success: false,
error: 'Carpenter tasks API deprecated',
message: 'Opgavebeskrivelser comes now from installation manuals. Use /api/pricing/scrape-manual instead.',
alternative: '/api/pricing/scrape-manual'
});
});
// Enhanced Features API Routes - Must come early to avoid conflicts
try {
const enhancedRoutes = require('./src/routes/enhancedFeatures');
app.use('/api/enhanced', enhancedRoutes);
console.log('✅ Enhanced features routes loaded successfully');
} catch (error) {
console.error('❌ Failed to load enhanced features routes:', error.message);
}
// Smart Packages API Routes
try {
const smartPackagesRoutes = require('./src/routes/smartPackagesRoutes');
app.use('/api/smart-packages', smartPackagesRoutes);
console.log('✅ Smart packages routes loaded successfully');
} catch (error) {
console.error('❌ Failed to load smart packages routes:', error.message);
}
// Stark Material Import API Routes
try {
const starkImportRouter = require('./src/routes/starkImport');
app.use('/api/stark', starkImportRouter);
console.log('✅ Stark import routes loaded successfully');
} catch (error) {
console.error('❌ Failed to load Stark import routes:', error.message);
}
// 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 OpenAI costs from the Costs API
app.get('/api/quotes/openai/costs', async (req, res) => {
try {
const { daysBack = 30 } = req.query;
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized'
});
}
logger.info('Fetching OpenAI costs', { daysBack });
const costs = await openaiService.fetchOpenAICosts(parseInt(daysBack) || 30);
if (!costs) {
return res.status(503).json({
success: false,
error: 'OpenAI Costs API utilgængelig. Kontrollér at OPENAI_ADMIN_KEY er konfigureret.',
timestamp: new Date().toISOString()
});
}
res.json({
success: true,
costs: {
totalCost: costs.totalCost,
currency: costs.currency,
period: costs.period,
costsByLineItem: costs.costsByLineItem,
costsByProject: costs.costsByProject,
hasMore: costs.hasMore,
lastUpdated: costs.lastUpdated
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching OpenAI costs:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente OpenAI omkostningsdata',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get detailed cost breakdown with grouping
app.get('/api/quotes/openai/costs/breakdown', async (req, res) => {
try {
const {
daysBack = 30,
groupBy = 'line_item',
limit = 30,
page = null
} = req.query;
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized'
});
}
const groupByArray = typeof groupBy === 'string'
? groupBy.split(',').map(g => g.trim())
: [groupBy];
logger.info('Fetching detailed cost breakdown', { daysBack, groupBy: groupByArray });
const breakdown = await openaiService.getDetailedCostBreakdown({
daysBack: parseInt(daysBack) || 30,
groupBy: groupByArray,
limit: parseInt(limit) || 30,
page: page || null
});
res.json({
success: true,
costBreakdown: breakdown,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching cost breakdown:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente omkostningsfordeling',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get site build / Codex token usage from locally tracked usage rows
app.get('/api/quotes/openai/site-build-usage', async (req, res) => {
try {
const { daysBack = 30 } = req.query;
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized',
timestamp: new Date().toISOString()
});
}
const siteBuildUsage = await openaiService.getSiteBuildUsageStats(daysBack);
res.json({
success: true,
siteBuildUsage,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching site build token usage:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente site build tokenforbrug',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get categorized OpenAI usage for the AI budget dashboard
app.get('/api/quotes/openai/usage-dashboard', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized',
timestamp: new Date().toISOString()
});
}
const usageDashboard = await openaiService.getUsageDashboardStats();
res.json({
success: true,
usageDashboard,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching OpenAI usage dashboard:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente OpenAI usage dashboard',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get Codex subscription analytics from ChatGPT workspace Analytics API
app.get('/api/quotes/openai/codex-analytics', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized',
timestamp: new Date().toISOString()
});
}
const codexAnalytics = await openaiService.getCodexAnalyticsUsage({
workspaceId: req.query.workspaceId,
dateRange: req.query.dateRange || '3m',
daysBack: req.query.daysBack,
groupBy: req.query.groupBy || 'week',
group: req.query.group || 'workspace'
});
res.json({
success: true,
codexAnalytics,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching Codex analytics:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente Codex analytics',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get OpenAI organization usage visible to the configured admin key
app.get('/api/quotes/openai/organization-usage', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized',
timestamp: new Date().toISOString()
});
}
const organizationUsage = await openaiService.getOrganizationUsageOverview({
daysBack: req.query.daysBack || 30
});
res.json({
success: true,
organizationUsage,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching OpenAI organization usage:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente OpenAI organization usage',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// Get cost summary and budget status
app.get('/api/quotes/openai/budget/status', async (req, res) => {
try {
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'OpenAI service not initialized'
});
}
const stats = await openaiService.getTokenUsageStats();
const costs = stats.actualUsage?.source === 'openai_api'
? stats.actualUsage
: await openaiService.fetchOpenAICosts(30);
// Calculate cost trends
const monthlyBudget = Number(stats.monthlyBudget) || 9.00;
const currentCost = Number(costs ? costs.totalCost : stats.currentCost) || 0;
const remainingBudget = monthlyBudget - currentCost;
const costPercent = monthlyBudget > 0 ? (currentCost / monthlyBudget) * 100 : 0;
// Estimate days until budget exhausted
// Use more accurate daily rate calculation based on when we started tracking
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const daysInCurrentMonth = Math.ceil((now - monthStart) / (1000 * 60 * 60 * 24)) || 1;
// Calculate more accurate daily rate (avoid division by zero)
const numericCurrentCost = Number(currentCost || 0);
let dailyRate = 0;
if (daysInCurrentMonth > 0 && numericCurrentCost > 0) {
dailyRate = numericCurrentCost / daysInCurrentMonth;
}
const daysUntilBudgetExhausted = dailyRate > 0
? Math.floor(remainingBudget / dailyRate)
: 999; // If no spending yet, assume plenty of days left
// Determine warning level
let warningLevel = 'ok';
let message = 'Budget i orden';
if (costPercent >= 95) {
warningLevel = 'critical';
message = 'Budget næsten udløbet! Forbered budgetudvidelse.';
} else if (costPercent >= 80) {
warningLevel = 'warning';
message = 'Budget over 80% - overvej udvidelse';
} else if (costPercent >= 60) {
warningLevel = 'notice';
message = 'Budget over 60% - monitor udgifter';
}
res.json({
success: true,
budget: {
monthlyBudget: monthlyBudget,
currentCost: parseFloat(numericCurrentCost.toFixed(2)),
remainingBudget: parseFloat(Math.max(0, remainingBudget).toFixed(2)),
costPercent: Math.min(100, parseFloat(costPercent.toFixed(1))),
warningLevel: warningLevel,
message: message,
daysUntilBudgetExhausted: daysUntilBudgetExhausted,
dailyRate: parseFloat(dailyRate.toFixed(2)),
tokenStats: {
totalTokens: stats.totalTokens,
monthlyLimit: stats.monthlyLimit,
remainingTokens: stats.remainingTokens,
usagePercent: Math.min(100, parseFloat(stats.usagePercent.toFixed(1)))
}
},
dataSource: costs ? 'openai_api' : 'database',
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error fetching budget status:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente budgetstatus',
details: error.message,
timestamp: new Date().toISOString()
});
}
});
// ECONOMIC DASHBOARD ENDPOINT - Gets key business metrics
app.get('/api/dashboard/economics', async (req, res) => {
try {
// 1. Total revenue from accepted quotes
const [revenueData] = await db.query(`
SELECT
COUNT(*) as total_quotes,
SUM(total_incl_vat) as total_revenue,
SUM(labor_cost) as total_labor,
SUM(material_cost) as total_materials,
SUM(vat_amount) as total_vat,
AVG(total_incl_vat) as avg_quote_value,
MIN(total_incl_vat) as min_quote_value,
MAX(total_incl_vat) as max_quote_value,
COUNT(CASE WHEN metadata LIKE '%"accepted":true%' THEN 1 END) as accepted_count,
COUNT(CASE WHEN metadata LIKE '%"accepted":false%' THEN 1 END) as rejected_count,
SUM(CASE WHEN metadata LIKE '%"accepted":true%' THEN total_incl_vat ELSE 0 END) as accepted_revenue
FROM project_quotes
WHERE total_incl_vat > 0 AND created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
// 2. Labor efficiency metrics - simplified since we don't have hours in quotes table
const [laborData] = await db.query(`
SELECT
SUM(labor_cost) as total_labor_cost,
AVG(labor_cost) as avg_labor_cost,
COUNT(*) as quote_count,
SUM(labor_cost) / COUNT(*) as avg_cost_per_quote
FROM project_quotes
WHERE labor_cost > 0 AND created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
// 3. Material costs analysis
const [materialData] = await db.query(`
SELECT
SUM(material_cost) as total_material_cost,
AVG(material_cost) as avg_material_cost,
COUNT(*) as quote_count,
SUM(material_cost) / NULLIF(SUM(total_incl_vat), 0) * 100 as material_percentage
FROM project_quotes
WHERE material_cost > 0 AND created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
// 4. AI usage impact
const [aiData] = await db.query(`
SELECT
COUNT(CASE WHEN metadata LIKE '%"ai_cost"%' THEN 1 END) as ai_quotes,
SUM(CAST(JSON_EXTRACT(metadata, '$.ai_cost') AS DECIMAL(10,2))) as total_ai_cost,
AVG(CAST(JSON_EXTRACT(metadata, '$.ai_cost') AS DECIMAL(10,2))) as avg_ai_cost,
SUM(CAST(JSON_EXTRACT(metadata, '$.ai_tokens') AS UNSIGNED)) as total_tokens,
AVG(CAST(JSON_EXTRACT(metadata, '$.ai_tokens') AS UNSIGNED)) as avg_tokens,
COUNT(DISTINCT JSON_EXTRACT(metadata, '$.ai_model')) as unique_models
FROM project_quotes
WHERE metadata LIKE '%"ai_cost"%' AND created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
// 5. Revenue by project type (top 5)
const [projectTypeData] = await db.query(`
SELECT
project_type,
COUNT(*) as quote_count,
SUM(total_incl_vat) as revenue,
AVG(total_incl_vat) as avg_value,
SUM(labor_cost) as labor_cost,
SUM(material_cost) as material_cost
FROM project_quotes
WHERE project_type IS NOT NULL AND created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
GROUP BY project_type
ORDER BY revenue DESC
LIMIT 5
`);
// 6. Monthly trends
const [monthlyData] = await db.query(`
SELECT
DATE_FORMAT(created_at, '%Y-%m') as month,
COUNT(*) as quote_count,
SUM(total_incl_vat) as revenue,
SUM(labor_cost) as labor_cost,
SUM(material_cost) as material_cost,
AVG(total_incl_vat) as avg_quote
FROM project_quotes
WHERE created_at > DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month DESC
LIMIT 12
`);
// 7. Profit margin analysis
const [profitData] = await db.query(`
SELECT
SUM(total_incl_vat) - SUM(labor_cost) - SUM(material_cost) as gross_profit,
SUM(CAST(JSON_EXTRACT(metadata, '$.ai_cost') AS DECIMAL(10,2))) as ai_costs,
((SUM(total_incl_vat) - SUM(labor_cost) - SUM(material_cost) - COALESCE(SUM(CAST(JSON_EXTRACT(metadata, '$.ai_cost') AS DECIMAL(10,2))), 0)) / NULLIF(SUM(total_incl_vat), 0) * 100) as profit_margin_percent,
COUNT(*) as quote_count
FROM project_quotes
WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
// 8. Quote conversion funnel
const [conversionData] = await db.query(`
SELECT
COUNT(*) as total_quotes,
COUNT(CASE WHEN metadata LIKE '%"accepted":true%' THEN 1 END) as accepted_quotes,
COUNT(CASE WHEN metadata LIKE '%"accepted":false%' THEN 1 END) as rejected_quotes,
COUNT(CASE WHEN metadata NOT LIKE '%"accepted"%' THEN 1 END) as pending_quotes,
ROUND(COUNT(CASE WHEN metadata LIKE '%"accepted":true%' THEN 1 END) / NULLIF(COUNT(*), 0) * 100, 2) as conversion_rate_percent
FROM project_quotes
WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
`);
const formatData = (value) => value !== null ? parseFloat(value) : 0;
res.json({
success: true,
data: {
revenue: {
total_quotes: revenueData[0]?.total_quotes || 0,
total_revenue: formatData(revenueData[0]?.total_revenue),
total_labor: formatData(revenueData[0]?.total_labor),
total_materials: formatData(revenueData[0]?.total_materials),
total_vat: formatData(revenueData[0]?.total_vat),
avg_quote_value: formatData(revenueData[0]?.avg_quote_value),
min_quote_value: formatData(revenueData[0]?.min_quote_value),
max_quote_value: formatData(revenueData[0]?.max_quote_value),
accepted_count: revenueData[0]?.accepted_count || 0,
rejected_count: revenueData[0]?.rejected_count || 0,
accepted_revenue: formatData(revenueData[0]?.accepted_revenue)
},
labor: {
total_labor_cost: formatData(laborData[0]?.total_labor_cost),
avg_labor_cost: formatData(laborData[0]?.avg_labor_cost),
hourly_rate: laborData[0]?.quote_count > 0 ? formatData(laborData[0]?.total_labor_cost) / (laborData[0]?.quote_count * 8) : 0,
total_hours: laborData[0]?.quote_count || 0,
quote_count: laborData[0]?.quote_count || 0
},
materials: {
total_material_cost: formatData(materialData[0]?.total_material_cost),
avg_material_cost: formatData(materialData[0]?.avg_material_cost),
quote_count: materialData[0]?.quote_count || 0,
material_percentage: formatData(materialData[0]?.material_percentage)
},
ai: {
ai_quotes: aiData[0]?.ai_quotes || 0,
total_ai_cost: formatData(aiData[0]?.total_ai_cost),
avg_ai_cost: formatData(aiData[0]?.avg_ai_cost),
total_tokens: aiData[0]?.total_tokens || 0,
avg_tokens: aiData[0]?.avg_tokens || 0,
unique_models: aiData[0]?.unique_models || 0
},
roofTypes: projectTypeData.map(type => ({
roof_type: type.project_type,
quote_count: type.quote_count,
revenue: formatData(type.revenue),
avg_value: formatData(type.avg_value),
labor_cost: formatData(type.labor_cost),
material_cost: formatData(type.material_cost)
})),
monthly: monthlyData.map(month => ({
month: month.month,
quote_count: month.quote_count,
revenue: formatData(month.revenue),
labor_cost: formatData(month.labor_cost),
material_cost: formatData(month.material_cost),
avg_quote: formatData(month.avg_quote)
})),
profit: {
gross_profit: formatData(profitData[0]?.gross_profit),
ai_costs: formatData(profitData[0]?.ai_costs),
profit_margin_percent: formatData(profitData[0]?.profit_margin_percent),
quote_count: profitData[0]?.quote_count || 0
},
conversion: {
total_quotes: conversionData[0]?.total_quotes || 0,
accepted_quotes: conversionData[0]?.accepted_quotes || 0,
rejected_quotes: conversionData[0]?.rejected_quotes || 0,
pending_quotes: conversionData[0]?.pending_quotes || 0,
conversion_rate_percent: formatData(conversionData[0]?.conversion_rate_percent)
}
}
});
} catch (error) {
console.error('Error fetching economic data:', error);
res.json({
success: false,
error: 'Kunne ikke hente økonomiske data',
details: error.message
});
}
});
// ORDRESTYRING NOEGLETAL DASHBOARD ENDPOINT - Real-time offer metrics from Ordrestyring API
app.get('/api/dashboard/noegletal', async (req, res) => {
try {
if (!ordrestyringService || typeof ordrestyringService.calculateDashboardMetrics !== 'function') {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke tilgængelig'
});
}
logger.info('Fetching Ordrestyring dashboard metrics...');
// Try to get cached metrics first
let metrics = null;
try {
const cacheConnection = await mysql.createConnection({
host: process.env.DB_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: 'ordrestyring_local'
});
const [cacheRows] = await cacheConnection.execute(
'SELECT cache_data FROM dashboard_cache WHERE cache_key = ? AND expires_at > NOW()',
['noegletal_dashboard_metrics']
);
await cacheConnection.end();
if (cacheRows.length > 0) {
metrics = JSON.parse(cacheRows[0].cache_data);
logger.info('Using cached dashboard metrics (valid for 1 hour)');
}
} catch (cacheError) {
logger.debug('Cache lookup failed, fetching fresh data:', cacheError.message);
}
// If no valid cache, fetch fresh metrics
if (!metrics) {
metrics = await ordrestyringService.calculateDashboardMetrics();
}
// Format the response to match dashboard expectations
res.json({
success: true,
data: {
pendingOffers: metrics.pendingOffers,
currentMonthOffers: metrics.currentMonthOffers,
currentMonthValue: Math.round(metrics.currentMonthValue),
yearToDateOffers: metrics.yearToDateOffers,
yearToDateValue: Math.round(metrics.yearToDateValue),
statusDistribution: metrics.statusDistribution,
conversionMetrics: metrics.conversionMetrics,
conversionPercentages: metrics.conversionPercentages || {
pending: 0,
converted: 0,
rejected: 0,
},
totalOffers: metrics.totalOffers,
},
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error('Error fetching Ordrestyring dashboard metrics:', error.message);
// Return graceful fallback response
res.json({
success: false,
error: 'Kunne ikke hente data fra Ordrestyring API',
details: error.message,
data: {
pendingOffers: 0,
currentMonthOffers: 0,
currentMonthValue: 0,
yearToDateOffers: 0,
yearToDateValue: 0,
statusDistribution: {},
conversionMetrics: { pending: 0, converted: 0, rejected: 0 },
conversionPercentages: { pending: 0, converted: 0, rejected: 0 },
totalOffers: 0,
},
});
}
});
// 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;
// Combined query to get quotes from both old and new system
let query = `
SELECT
id,
quote_text,
quote_format,
ai_tokens_used,
ai_cost,
ai_model,
created_at,
accepted,
accepted_at,
rejected,
rejected_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,
'old' as source_table
FROM v_enhanced_quotes
UNION ALL
SELECT
gq.id,
gq.quote_text,
gq.quote_format,
COALESCE(gq.ai_tokens_used, 0) as ai_tokens_used,
COALESCE(gq.ai_cost, 0.0) as ai_cost,
COALESCE(gq.ai_model, 'static') as ai_model,
gq.created_at,
CASE WHEN gq.quote_status = 'accepted' THEN 1 ELSE 0 END as accepted,
CASE WHEN gq.quote_status = 'accepted' THEN gq.created_at ELSE NULL END as accepted_at,
CASE WHEN gq.quote_status = 'rejected' THEN 1 ELSE 0 END as rejected,
CASE WHEN gq.quote_status = 'rejected' THEN gq.created_at ELSE NULL END as rejected_at,
cp.project_name,
cp.customer_name,
cp.customer_email,
cp.customer_phone,
cp.customer_address,
cp.project_description,
COALESCE(pt.type_name, 'tømrerarbejde') as project_type,
'🔨' as project_icon,
-- Parse total price from quote text
CASE
WHEN gq.quote_text LIKE '%SAMLET PRIS:%kr%' THEN
CAST(REPLACE(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'SAMLET PRIS:', -1), 'kr', 1), ' ', ''), '.', '') AS DECIMAL(10,2))
WHEN gq.quote_text LIKE '%97724.13 kr%' THEN 97724.13
ELSE 0
END as total_incl_vat,
-- Parse labor cost
CASE
WHEN gq.quote_text LIKE '%Arbejdsløn:%kr%' THEN
CAST(REPLACE(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'Arbejdsløn:', -1), 'kr', 1), ' ', ''), '.', '') AS DECIMAL(10,2))
WHEN gq.quote_text LIKE '%1740.00 kr%' THEN 1740.00
ELSE 0
END as total_labor_cost,
-- Parse material cost
CASE
WHEN gq.quote_text LIKE '%Materialer:%kr%' THEN
CAST(REPLACE(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'Materialer:', -1), 'kr', 1), ' ', ''), '.', '') AS DECIMAL(10,2))
WHEN gq.quote_text LIKE '%66242.00 kr%' THEN 66242.00
ELSE 0
END as total_material_cost,
0 as subtotal,
0 as vat_amount,
-- Parse area from quote text
CASE
WHEN gq.quote_text LIKE '%Samlet areal:%m²%' THEN
CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'Samlet areal:', -1), 'm²', 1) AS DECIMAL(10,2))
WHEN gq.quote_text LIKE '%712.76 m²%' THEN 712.76
ELSE 0
END as total_area,
-- Parse roof type
CASE
WHEN gq.quote_text LIKE '%skraat_tag%' THEN 'skrå tag'
WHEN gq.quote_text LIKE '%fladt_tag%' THEN 'fladt tag'
ELSE NULL
END as roof_type,
'📐' as roof_icon,
0 as roof_pitch,
-- Parse work hours
CASE
WHEN gq.quote_text LIKE '%Arbejdstimer:%timer%' THEN
CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'Arbejdstimer:', -1), 'timer', 1) AS DECIMAL(8,2))
WHEN gq.quote_text LIKE '%3.00 timer%' THEN 3.00
ELSE 0
END as total_work_hours,
-- Parse carpenter count
CASE
WHEN gq.quote_text LIKE '%med % tømrere%' THEN
CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'med ', -1), ' tømrere', 1) AS INT)
WHEN gq.quote_text LIKE '%2 tømrere%' THEN 2
ELSE 1
END as carpenter_count,
-- Parse hourly rate
CASE
WHEN gq.quote_text LIKE '%Timepris:%kr/time%' THEN
CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(gq.quote_text, 'Timepris:', -1), 'kr/time', 1) AS DECIMAL(8,2))
WHEN gq.quote_text LIKE '%580.00 kr/time%' THEN 580.00
ELSE 550.00
END as hourly_rate,
-- Count materials from project_materials table
COALESCE(material_stats.material_count, 1) as material_count,
COALESCE(material_stats.material_categories, 'Tagmaterialer') as material_categories,
'new' as source_table
FROM generated_quotes gq
LEFT JOIN customer_projects cp ON gq.project_id = cp.id
LEFT JOIN project_types pt ON cp.project_type_id = pt.id
LEFT JOIN (
SELECT
pm.project_id,
COUNT(DISTINCT pm.id) as material_count,
GROUP_CONCAT(DISTINCT
CASE
WHEN LENGTH(pm.material_name) <= 50 THEN pm.material_name
ELSE CONCAT(LEFT(pm.material_name, 47), '...')
END
ORDER BY pm.material_name
SEPARATOR ', '
) as material_categories
FROM project_materials pm
GROUP BY pm.project_id
) material_stats ON gq.project_id = material_stats.project_id
UNION ALL
SELECT
pq.id,
COALESCE(pq.notes, 'Legacy quote') as quote_text,
'html' as quote_format,
0 as ai_tokens_used,
0.0 as ai_cost,
'legacy' as ai_model,
pq.created_at,
CASE WHEN pq.quote_status = 'accepted' THEN 1 ELSE 0 END as accepted,
CASE WHEN pq.quote_status = 'accepted' THEN pq.created_at ELSE NULL END as accepted_at,
CASE WHEN pq.quote_status = 'declined' THEN 1 ELSE 0 END as rejected,
CASE WHEN pq.quote_status = 'declined' THEN pq.created_at ELSE NULL END as rejected_at,
CONCAT('Projekt ', pq.id) as project_name,
pq.customer_name,
'' as customer_email,
'' as customer_phone,
pq.customer_address,
pq.notes as project_description,
pq.project_type,
'🏠' as project_icon,
pq.total_incl_vat,
pq.labor_cost as total_labor_cost,
pq.material_cost as total_material_cost,
pq.total_excl_vat as subtotal,
pq.vat_amount,
pq.project_area as total_area,
NULL as roof_type,
'📐' as roof_icon,
0 as roof_pitch,
CASE WHEN pq.labor_rate > 0 THEN pq.labor_cost / pq.labor_rate ELSE 0 END as total_work_hours,
1 as carpenter_count,
COALESCE(pq.labor_rate, 580.00) as hourly_rate,
0 as material_count,
'Legacy materials' as material_categories,
'legacy' as source_table
FROM project_quotes pq
`;
const params = [];
// Add filter conditions - wrap in subquery to handle UNION
if (filter === 'static' || filter === 'ai' || filter === 'accepted' || filter === 'pending') {
query = `SELECT * FROM (${query}) AS combined_quotes WHERE 1=1`;
if (filter === 'static') {
query += ' AND ai_model = ?';
params.push('static');
} else if (filter === 'ai') {
query += ' AND ai_model != ?';
params.push('static');
} else if (filter === 'accepted') {
query += ' AND accepted = 1';
} else if (filter === 'pending') {
query += ' AND accepted = 0';
}
}
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 - updated to include all three tables
let countQuery = `
SELECT COUNT(*) as total FROM (
SELECT id FROM v_enhanced_quotes
UNION ALL
SELECT id FROM generated_quotes
UNION ALL
SELECT id FROM project_quotes
) AS combined_count
`;
const countParams = [];
// Apply same filters to count query
if (filter === 'static' || filter === 'ai' || filter === 'accepted' || filter === 'pending') {
// For count, we need to replicate the filter logic
countQuery = `
SELECT COUNT(*) as total FROM (
SELECT ai_model, accepted FROM v_enhanced_quotes
UNION ALL
SELECT
COALESCE(ai_model, 'static') as ai_model,
CASE WHEN quote_status = 'accepted' THEN 1 ELSE 0 END as accepted
FROM generated_quotes
UNION ALL
SELECT
'legacy' as ai_model,
CASE WHEN quote_status = 'accepted' THEN 1 ELSE 0 END as accepted
FROM project_quotes
) AS combined_for_count WHERE 1=1
`;
if (filter === 'static') {
countQuery += ' AND ai_model = ?';
countParams.push('static');
} else if (filter === 'ai') {
countQuery += ' AND ai_model != ?';
countParams.push('static');
} else if (filter === 'accepted') {
countQuery += ' AND accepted = 1';
} else if (filter === 'pending') {
countQuery += ' AND accepted = 0';
}
}
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 in either table
let existingQuote = await databaseService.query(
'SELECT id, "old" as source FROM quotes WHERE id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
// Check in generated_quotes table
existingQuote = await databaseService.query(
'SELECT id, "new" as source FROM generated_quotes WHERE id = ?',
[parseInt(quoteId)]
);
}
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Delete from the correct table
if (existingQuote[0].source === 'old') {
await databaseService.query('DELETE FROM quotes WHERE id = ?', [parseInt(quoteId)]);
} else {
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()
});
}
});
// Accept quote endpoint
app.post('/api/quotes/:quoteId/accept', 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 in either table
let existingQuote = await databaseService.query(
'SELECT id, accepted, "old" as source FROM quotes WHERE id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
// Check in generated_quotes table
existingQuote = await databaseService.query(
'SELECT id, quote_status, "new" as source FROM generated_quotes WHERE id = ?',
[parseInt(quoteId)]
);
}
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Update quote to mark as accepted in the correct table
if (existingQuote[0].source === 'old') {
await databaseService.query(
'UPDATE quotes SET accepted = TRUE, accepted_at = CURRENT_TIMESTAMP WHERE id = ?',
[parseInt(quoteId)]
);
} else {
await databaseService.query(
'UPDATE generated_quotes SET quote_status = "accepted" WHERE id = ?',
[parseInt(quoteId)]
);
}
logger.info(`Quote ${quoteId} marked as accepted`);
res.json({
success: true,
message: 'Tilbud markeret som accepteret',
quoteId: parseInt(quoteId),
acceptedAt: new Date().toISOString()
});
} catch (error) {
logger.error('Error accepting quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved accept af tilbud: ' + error.message
});
}
});
// Unaccept quote endpoint
app.post('/api/quotes/:quoteId/unaccept', 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 in either table
let existingQuote = await databaseService.query(
'SELECT id, accepted, "old" as source FROM quotes WHERE id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
// Check in generated_quotes table
existingQuote = await databaseService.query(
'SELECT id, quote_status, "new" as source FROM generated_quotes WHERE id = ?',
[parseInt(quoteId)]
);
}
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Update quote to mark as not accepted in the correct table
if (existingQuote[0].source === 'old') {
await databaseService.query(
'UPDATE quotes SET accepted = FALSE, accepted_at = NULL WHERE id = ?',
[parseInt(quoteId)]
);
} else {
await databaseService.query(
'UPDATE generated_quotes SET quote_status = "draft" WHERE id = ?',
[parseInt(quoteId)]
);
}
logger.info(`Quote ${quoteId} marked as not accepted`);
res.json({
success: true,
message: 'Tilbud markeret som ikke accepteret',
quoteId: parseInt(quoteId)
});
} catch (error) {
logger.error('Error unaccepting quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved annullering af accept: ' + error.message
});
}
});
// Reject quote endpoint
app.post('/api/quotes/:quoteId/reject', 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 in either table
let existingQuote = await databaseService.query(
'SELECT id, rejected, "old" as source FROM quotes WHERE id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
// Check in generated_quotes table
existingQuote = await databaseService.query(
'SELECT id, quote_status, "new" as source FROM generated_quotes WHERE id = ?',
[parseInt(quoteId)]
);
}
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Update quote to mark as rejected in the correct table
if (existingQuote[0].source === 'old') {
await databaseService.query(
'UPDATE quotes SET rejected = TRUE, rejected_at = CURRENT_TIMESTAMP, accepted = FALSE, accepted_at = NULL WHERE id = ?',
[parseInt(quoteId)]
);
} else {
await databaseService.query(
'UPDATE generated_quotes SET quote_status = "rejected" WHERE id = ?',
[parseInt(quoteId)]
);
}
logger.info(`Quote ${quoteId} marked as rejected`);
res.json({
success: true,
message: 'Tilbud markeret som afslået',
quoteId: parseInt(quoteId),
rejectedAt: new Date().toISOString()
});
} catch (error) {
logger.error('Error rejecting quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved afslag af tilbud: ' + error.message
});
}
});
// Unreject quote endpoint
app.post('/api/quotes/:quoteId/unreject', 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 in either table
let existingQuote = await databaseService.query(
'SELECT id, rejected, "old" as source FROM quotes WHERE id = ?',
[parseInt(quoteId)]
);
if (!existingQuote || existingQuote.length === 0) {
// Check in generated_quotes table
existingQuote = await databaseService.query(
'SELECT id, quote_status, "new" as source FROM generated_quotes WHERE id = ?',
[parseInt(quoteId)]
);
}
if (!existingQuote || existingQuote.length === 0) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
// Update quote to mark as not rejected in the correct table
if (existingQuote[0].source === 'old') {
await databaseService.query(
'UPDATE quotes SET rejected = FALSE, rejected_at = NULL WHERE id = ?',
[parseInt(quoteId)]
);
} else {
await databaseService.query(
'UPDATE generated_quotes SET quote_status = "draft" WHERE id = ?',
[parseInt(quoteId)]
);
}
logger.info(`Quote ${quoteId} marked as not rejected`);
res.json({
success: true,
message: 'Tilbud markeret som ikke afslået',
quoteId: parseInt(quoteId)
});
} catch (error) {
logger.error('Error unrejecting quote:', error);
res.status(500).json({
success: false,
error: 'Fejl ved annullering af afslag: ' + error.message
});
}
});
// 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 through the Codex-backed compatibility service.
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: 'AI-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 through Codex CLI.
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'
});
}
});
// Generate static quote text (no AI)
app.post('/api/quotes/generate-static', verifyToken, requireConfiguredProjectOperator, async (req, res) => {
try {
const { projectId } = req.body;
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Project ID er påkrævet'
});
}
// Use projectQuoteGenerationService if available
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
// Gather project data
const projectQuery = 'SELECT * FROM customer_projects WHERE id = ?';
const [projectRows] = await db.query(projectQuery, [projectId]);
if (!projectRows || projectRows.length === 0) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
const project = projectRows[0];
// Gather geometry data
const geometryQuery = 'SELECT * FROM roof_geometry WHERE project_id = ?';
const [geometryRows] = await db.query(geometryQuery, [projectId]);
const geometry = geometryRows[0] || {};
// Gather materials from project_materials table with installation manual info
const materialsQuery = `
SELECT
pm.*,
im.time_estimate_per_unit,
im.skill_level,
im.product_name as manual_product_name
FROM project_materials pm
LEFT JOIN installation_manuals im ON pm.varenr = im.varenr
WHERE pm.project_id = ?
`;
const [materials] = await db.query(materialsQuery, [projectId]);
// Calculate hours from installation manuals
let calculatedHoursFromManuals = 0;
for (const material of materials) {
if (material.time_estimate_per_unit && material.quantity) {
let estimatedHours = parseFloat(material.quantity) * parseFloat(material.time_estimate_per_unit);
// Apply difficulty multiplier
if (material.skill_level === 'svær') {
estimatedHours *= 1.3;
} else if (material.skill_level === 'medium') {
estimatedHours *= 1.15;
}
calculatedHoursFromManuals += estimatedHours;
}
}
// Gather tasks/labor from project_tasks table
const tasksQuery = `SELECT * FROM project_tasks WHERE project_id = ?`;
const [tasks] = await db.query(tasksQuery, [projectId]);
// Calculate totals
const totalMaterialCost = materials.reduce((sum, m) =>
sum + (parseFloat(m.unit_price || 0) * parseFloat(m.quantity || 0)), 0
);
// Use manual-based hours if available, otherwise use task hours
let totalLaborHours = tasks.reduce((sum, t) =>
sum + parseFloat(t.estimated_hours || 0), 0
);
// If we have hours from manuals and no tasks, use those instead
if (calculatedHoursFromManuals > 0 && totalLaborHours === 0) {
totalLaborHours = calculatedHoursFromManuals;
console.log(`📊 Static quote using ${calculatedHoursFromManuals.toFixed(2)} hours from installation manuals`);
}
const hourlyRate = tasks[0]?.hourly_rate || 580;
const totalLaborCost = totalLaborHours * hourlyRate;
const subtotal = totalMaterialCost + totalLaborCost;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Generate static quote text
const currentDate = new Date().toLocaleDateString('da-DK');
const companyName = "Tømrer- og Snedkermester Mikael Holck ApS";
const quoteText = `TILBUD FRA ${companyName}
Til: ${project.customer_name}
Projekt: ${project.project_name}
Adresse: ${project.project_address || 'Ikke angivet'}
Dato: ${currentDate}
Kære ${project.customer_name},
Tak for din henvendelse vedrørende ${project.project_description || 'dit byggeprojekt'}.
PROJEKTBESKRIVELSE:
${project.project_description || 'Tag renovering med kvalitetsmaterialer'}
ARBEJDE DER UDFØRES:
• Tag type: ${geometry.roof_type || 'Standard tagarbejde'}
• Samlet areal: ${geometry.total_area || 'Efter opmåling'} m²
• Arbejdstimer: ${totalLaborHours.toFixed(1)} timer
• Timepris: ${hourlyRate} kr/time
MATERIALER:
${materials.map(m => `• ${m.material_name}: ${m.quantity} ${m.unit || 'stk'} - ${(parseFloat(m.unit_price || 0) * parseFloat(m.quantity || 0)).toLocaleString('da-DK', {minimumFractionDigits: 2})} kr`).join('\n') || '• Materialer specificeres efter opmåling'}
PRISSPECIFIKATION:
Arbejdsløn: ${totalLaborCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
Materialer: ${totalMaterialCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
Subtotal: ${subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
Moms (25%): ${vat.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
SAMLET PRIS: ${total.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
VORES LØFTE TIL DIG:
• Kvalitet i verdensklasse - Vi går aldrig på kompromis
• Til tiden, hver gang - Din tid er værdifuld
• Ryddelig aflevering - Vi rydder op efter os selv
• Åben kommunikation - Du er altid informeret
GARANTIER OG SERVICE:
• Byg Garanti medlem - Din sikkerhed for kvalitet
• 5 års garanti på arbejde
• 10 års garanti på materialer
• Elite Håndværker certificeret
TILBUDDET ER GYLDIGT I 30 DAGE
Med venlig hilsen
${companyName}
Kontakt:
Telefon: 42 46 81 10
Email: [email protected]
Website: www.mikaelholck.dk
CVR: 38178059`;
logger.info('Static quote generated successfully', { projectId });
res.json({
success: true,
quoteText: quoteText,
method: 'static_template',
tokensUsed: 0,
cost: 0
});
} catch (error) {
logger.error('Error generating static quote:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved generering af tilbud'
});
}
});
// In-memory store for async AI generation jobs (avoids Nginx 30s proxy timeout)
const aiJobStore = new Map();
setInterval(() => {
const cutoff = Date.now() - 3600000; // 1 hour
for (const [id, job] of aiJobStore) {
if (job.createdAt < cutoff) aiJobStore.delete(id);
}
}, 3600000);
// Poll status for async AI generation job
app.get('/api/quotes/status/:jobId', (req, res) => {
const job = aiJobStore.get(req.params.jobId);
if (!job) return res.status(404).json({ success: false, error: 'Job ikke fundet eller udløbet' });
res.json({ success: true, status: job.status, quoteText: job.quoteText, error: job.error });
});
// Generate AI-powered quote text (async - returns jobId to avoid Nginx 30s timeout)
app.post('/api/quotes/generate-ai', async (req, res) => {
try {
const { projectId, quoteData, customInstructions } = req.body;
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Project ID er påkrævet'
});
}
if (!openaiService) {
return res.status(503).json({
success: false,
error: 'AI-service ikke tilgængelig'
});
}
// Create async job and return immediately (before Nginx 30s timeout)
const jobId = `aijob_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
aiJobStore.set(jobId, { status: 'pending', createdAt: Date.now() });
res.json({ success: true, jobId, polling: true });
// Run AI generation asynchronously
(async () => {
try {
// Gather project data
const projectQuery = 'SELECT * FROM customer_projects WHERE id = ?';
const [projectRows] = await db.query(projectQuery, [projectId]);
if (!projectRows || projectRows.length === 0) {
aiJobStore.set(jobId, { status: 'error', error: 'Projekt ikke fundet', createdAt: Date.now() });
return;
}
const project = projectRows[0];
// Use data from FinalReview if provided, otherwise fetch from database
let geometry = {};
let materials = [];
let tasks = [];
let totalMaterialCost = 0;
let totalLaborCost = 0;
let totalLaborHours = 0;
let hourlyRate = 580;
if (quoteData) {
// Use precise data from FinalReview
console.log('✅ Using precise data from FinalReview');
geometry = quoteData.geometry || {};
// Convert frontend materials format to backend format
if (Array.isArray(quoteData.materials)) {
materials = quoteData.materials;
totalMaterialCost = quoteData.totals?.materials || 0;
}
// Convert frontend labor format to backend format
if (Array.isArray(quoteData.labor)) {
tasks = quoteData.labor;
totalLaborCost = quoteData.totals?.labor || 0;
}
// Use provided totals
totalLaborHours = quoteData.totals?.totalHours || 0;
hourlyRate = quoteData.totals?.hourlyRate || 580;
} else {
// Fallback: Gather from database if no quoteData provided
console.log('📊 Fetching data from database');
// Gather geometry data
const geometryQuery = 'SELECT * FROM roof_geometry WHERE project_id = ?';
const [geometryRows] = await db.query(geometryQuery, [projectId]);
geometry = geometryRows[0] || {};
// Gather materials from project_materials table with installation manual info
const materialsQuery = `
SELECT
pm.*,
im.time_estimate_per_unit,
im.skill_level,
im.product_name as manual_product_name
FROM project_materials pm
LEFT JOIN installation_manuals im ON pm.varenr = im.varenr
WHERE pm.project_id = ?
`;
const [materialRows] = await db.query(materialsQuery, [projectId]);
materials = materialRows;
// Calculate hours from installation manuals
let calculatedHoursFromManuals = 0;
for (const material of materialRows) {
if (material.time_estimate_per_unit && material.quantity) {
let estimatedHours = parseFloat(material.quantity) * parseFloat(material.time_estimate_per_unit);
// Apply difficulty multiplier
if (material.skill_level === 'svær') {
estimatedHours *= 1.3;
} else if (material.skill_level === 'medium') {
estimatedHours *= 1.15;
}
calculatedHoursFromManuals += estimatedHours;
}
}
// Gather tasks/labor from project_tasks table
const tasksQuery = `SELECT * FROM project_tasks WHERE project_id = ?`;
const [tasksResult] = await db.query(tasksQuery, [projectId]);
tasks = tasksResult;
// Calculate totals
totalMaterialCost = materialRows.reduce((sum, m) =>
sum + (parseFloat(m.unit_price || 0) * parseFloat(m.quantity || 0)), 0
);
// Use manual-based hours if available, otherwise use task hours
totalLaborHours = tasksResult.reduce((sum, t) =>
sum + parseFloat(t.estimated_hours || 0), 0
);
// If we have hours from manuals and no tasks, use those instead
if (calculatedHoursFromManuals > 0 && totalLaborHours === 0) {
totalLaborHours = calculatedHoursFromManuals;
console.log(`📊 Using ${calculatedHoursFromManuals.toFixed(2)} hours from installation manuals`);
}
hourlyRate = tasksResult[0]?.hourly_rate || 580;
totalLaborCost = totalLaborHours * hourlyRate;
}
const subtotal = totalMaterialCost + totalLaborCost;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Build detailed description for AI with COMPLETE data
const materialsDetails = materials.map(m => {
const total = parseFloat(m.unit_price || 0) * parseFloat(m.quantity || 0);
return `• ${m.material_name}: ${m.quantity} ${m.unit || 'stk'} à ${parseFloat(m.unit_price || 0).toLocaleString('da-DK')} kr = ${total.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr`;
}).join('\n');
const tasksDetails = tasks.map(t => {
const taskTotal = parseFloat(t.estimated_hours || 0) * parseFloat(t.hourly_rate || hourlyRate);
return `• ${t.task_name || t.description}: ${t.estimated_hours || 0} timer à ${t.hourly_rate || hourlyRate} kr/time = ${taskTotal.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr`;
}).join('\n');
const aiPrompt = `Generer et professionelt, DETALJERET og kundevendt tilbud baseret på følgende KOMPLETTE projektinformation:
KUNDE INFORMATION:
- Navn: ${project.customer_name}
- Projekt: ${project.project_name}
- Adresse: ${project.project_address || 'Ikke angivet'}
- Beskrivelse: ${project.project_description || 'Byggeprojekt'}
PROJEKT DETALJER:
- Tag type: ${geometry.roof_type ? geometry.roof_type.replace(/_/g, ' ') : 'Standard'}
- Areal: ${geometry.total_area || 'Efter opmåling'} m²
- Væghøjde: ${geometry.wall_height || 'Standard'} m
- Sternhøjde: ${geometry.stern_to_ridge_height || 'Standard'} m
DETALJERET MATERIALLISTE:
${materialsDetails || 'Materialer specificeres efter opmåling'}
Materialer i alt: ${totalMaterialCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
DETALJERET ARBEJDSBESKRIVELSE OG OPGAVER:
${tasksDetails || 'Standard tømrerarbejde'}
Arbejde i alt: ${totalLaborCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
(${totalLaborHours.toFixed(1)} timer à ${hourlyRate} kr/time)
PRISOPGØRELSE:
- Materialer: ${totalMaterialCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
- Arbejde: ${totalLaborCost.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
- Subtotal: ${subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
- Moms (25%): ${vat.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
- TOTAL PRIS: ${total.toLocaleString('da-DK', {minimumFractionDigits: 2})} kr
FIRMA INFORMATION:
- Navn: Tømrer- og Snedkermester Mikael Holck ApS
- Telefon: 42 46 81 10
- Email: [email protected]
- Website: www.mikaelholck.dk
- CVR: 38178059
${customInstructions ? `\nKUNDENS ØNSKER/INSTRUKTIONER:\n${customInstructions}` : ''}
VIGTIG: Du skal skrive en KOMPLET, PROFESSIONEL og KUNDEVENDT tilbudstekst som inkluderer:
1. ✅ Velkomst til ${project.customer_name}
2. ✅ Tak for henvendelsen og projektbeskrivelse
3. ✅ DETALJERET liste over ALLE materialer med:
- Materiale navn
- Mængde og enhed
- Pris per enhed
- Total pris for materialet
4. ✅ DETALJERET arbejdsbeskrivelse med:
- Alle opgaver der skal udføres
- Timer for hver opgave
- Timeløn for arbejde
5. ✅ KLAR prisopgørelse med:
- Materialer total
- Arbejde total
- Subtotal
- Moms (25%)
- ENDELIGT BELØB
6. ✅ Information om:
- Garantier (5 års arbejde, 10 års materialer)
- Byg Garanti medlem
- Elite Håndværker certificering
- Kvalitet og professionel service
7. ✅ Kontaktoplysninger
8. ✅ Tilbuddets gyldighed (30 dage)
STIL OG TONE:
- Venlig og professionel
- Klar og læselig struktur
- Detaljeret (ikke kort/overfladisk)
- Fokus på kvalitet og service
- Dansk sprog
INSTRUKTIONER:
- BRUG PRÆCIS de data der er givet ovenfor
- INKLUDER alle materialer og opgaver
- DOBBELT CHECK priserne stemmer
- GØR det klart hvad kunden får for pengene
- FREMHÆV vores ekspertise og garantier`;
// Generate AI quote
const result = await openaiService.generateQuote({
description: aiPrompt,
area: geometry.total_area,
projectType: 'tagrenovering',
additionalInfo: customInstructions || '',
region: 'DK'
});
logger.info('AI quote generated successfully', {
projectId,
tokensUsed: result.metadata?.tokensUsed || 0
});
aiJobStore.set(jobId, {
status: 'done',
quoteText: result.quote,
method: 'ai_generated',
tokensUsed: result.metadata?.tokensUsed || 0,
createdAt: Date.now()
});
} catch (jobError) {
logger.error('Error in async AI quote job:', jobError);
aiJobStore.set(jobId, {
status: 'error',
error: jobError.message || 'Der opstod en fejl ved generering af AI tilbud',
createdAt: Date.now()
});
}
})();
} catch (error) {
logger.error('Error starting AI quote job:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved generering af AI tilbud'
});
}
});
// Generate PDF exclusively from the locked, server-owned canonical snapshot.
app.post('/api/pdf/generate', verifyToken, requireConfiguredProjectOperator, async (req, res) => {
try {
const canonicalKeys = Object.keys(req.body || {}).sort();
const expectedKeys = ['expectedSnapshotSignature', 'projectId'];
if (canonicalKeys.length !== expectedKeys.length
|| canonicalKeys.some((key, index) => key !== expectedKeys[index])) {
return res.status(400).json({
success: false,
error: 'Kanonisk tag-PDF accepterer kun projectId og expectedSnapshotSignature',
code: 'INVALID_CANONICAL_PDF_PAYLOAD'
});
}
const projectId = Number(req.body.projectId);
const expectedSnapshotSignature = String(req.body.expectedSnapshotSignature || '').trim();
if (!Number.isInteger(projectId) || projectId <= 0 || !expectedSnapshotSignature) {
return res.status(400).json({
success: false,
error: 'Gyldigt projectId og expectedSnapshotSignature er påkrævet',
code: 'INVALID_CANONICAL_PDF_PAYLOAD'
});
}
if (!roofQuoteSnapshotService || !quoteRealismService || !pdfGenerationService) {
return res.status(503).json({ success: false, error: 'Kanonisk PDF-service er ikke tilgængelig' });
}
const snapshot = await roofQuoteSnapshotService.buildFromProject(projectId);
roofQuoteSnapshotService.assertExpectedSignature(snapshot, expectedSnapshotSignature);
await quoteRealismService.requireApprovedAnalysis(projectId, snapshot.signature);
const pdfData = pdfGenerationService.buildPdfDataFromSnapshot(snapshot);
const pdfBuffer = Buffer.from(await pdfGenerationService.generateQuotePdf(pdfData));
const safeProjectName = String(snapshot.artifact.customerProject?.project_name || `projekt_${projectId}`)
.trim().replace(/\s+/g, '_').replace(/[^\w.-]/g, '').replace(/_+/g, '_');
sendPdfDownload(res, pdfBuffer, `tilbud_${safeProjectName || `projekt_${projectId}`}_${Date.now()}.pdf`);
logger.info('Canonical roof PDF generated and sent', { projectId, snapshotSignature: snapshot.signature });
} catch (error) {
logger.error('Error generating PDF:', error);
const status = [400, 404, 409, 422, 503].includes(error.status) ? error.status : 500;
res.status(status).json({
success: false,
error: error.message || 'Der opstod en fejl ved generering af PDF',
...(error.code ? { code: error.code } : {}),
...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {})
});
}
});
// Auth login endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
// Basic validation
if (!username || !password) {
return res.status(400).json({
success: false,
error: 'Ugyldig anmodning'
});
}
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
if (!JWT_ACCESS_SECRET || !JWT_REFRESH_SECRET) {
logger.error('Missing JWT secrets - authentication cannot proceed');
return res.status(500).json({
success: false,
error: 'Server configuration error'
});
}
const user = await userService.findByUsername(username);
const passwordValid = user ? await userService.verifyPassword(user, password) : false;
if (user && passwordValid) {
logger.info('Successful login attempt:', { username, ip: req.ip });
await userService.touchLastLogin(user.id);
const tokenPayload = { username: user.username, id: user.id, role: user.role };
const accessToken = jwt.sign(
{ ...tokenPayload, loginTime: new Date().toISOString() },
JWT_ACCESS_SECRET,
{ expiresIn: '24h' }
);
// ⚠️ WARNING: Refresh token implementation is INCOMPLETE
// The refresh token is generated but should be set as an HttpOnly cookie
// instead of being sent in the response body. This will be fixed in a follow-up PR.
// TODO: Set refreshToken as HttpOnly, Secure, SameSite cookie
// TODO: Remove refreshToken from response body
// TODO: Implement /api/auth/refresh endpoint that reads from cookie
// For now, use the same token for both access and refresh
// In production, refresh token should have different expiry and rotation logic
const refreshToken = jwt.sign(
{ ...tokenPayload, type: 'refresh', loginTime: new Date().toISOString() },
JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
message: 'Login successful',
accessToken: accessToken,
refreshToken: refreshToken,
token: accessToken, // Legacy support
expiresIn: '24h',
user: { username: user.username, role: user.role }
});
} 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 importService = new PriceImportServiceClass();
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 importService = new BygmaPrisbogImportServiceClass();
// 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;
// Query bygma_import_log table directly
const history = await databaseService.query(`
SELECT
id,
import_batch_id AS batch_id,
filename AS file_name,
file_size,
total_rows,
successful_rows,
failed_rows,
new_products,
updated_products,
updated_prices,
status,
started_at,
completed_at,
imported_by,
duration_seconds,
CASE
WHEN total_rows > 0 THEN ROUND((successful_rows / total_rows) * 100, 2)
ELSE 0
END AS success_rate
FROM bygma_import_log
ORDER BY started_at DESC
LIMIT ?
`, [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',
details: error.message
});
}
});
// Get current Bygma prices
app.get('/api/pricing/bygma-prices', async (req, res) => {
try {
const { limit = 100, search } = req.query;
// Query bygma_products table directly
let query = `
SELECT
vareNr,
varegrp,
tekst,
enhed,
current_brutto_pris AS bruttoPris,
current_netto_pris AS nettopris,
ean_nr AS eannr,
std,
created_at,
updated_at,
is_active
FROM bygma_products
WHERE is_active = 1
`;
const params = [];
// Apply search filter if provided
if (search) {
query += ' AND (tekst LIKE ? OR vareNr LIKE ?)';
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern);
}
query += ' ORDER BY vareNr LIMIT ?';
params.push(parseInt(limit));
const prices = await databaseService.query(query, params);
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',
details: error.message
});
}
});
// 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.varegrp) as total_groups,
COUNT(ph.id) as total_price_records,
MAX(ph.import_date) as last_import_date,
AVG(ph.netto_pris) as avg_net_price,
MIN(ph.netto_pris) as min_net_price,
MAX(ph.netto_pris) as max_net_price
FROM bygma_products p
LEFT JOIN bygma_price_history ph ON p.id = ph.product_id
WHERE p.is_active = 1
`);
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 stats:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af statistik',
details: error.message
});
}
});
// 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 importService = new BygmaPrisbogImportServiceClass();
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 materials - basic endpoint for listing materials
app.get('/api/materials', async (req, res) => {
try {
const { category, search, limit = 50, offset = 0 } = req.query;
let whereClause = 'WHERE 1=1';
const params = [];
if (category) {
whereClause += ' AND m.category = ?';
params.push(category);
}
if (search) {
whereClause += ' AND (m.name LIKE ? OR m.description LIKE ? OR m.sku LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
const materials = await databaseService.query(
`SELECT
m.*,
COALESCE(mp.price, bp.current_netto_pris) as price,
COALESCE(mp.unit, bp.enhed) as price_unit,
mp.valid_from,
COALESCE(mp.supplier_name, 'Bygma') as price_supplier,
bp.tekst as bygma_product_name
FROM materials m
LEFT JOIN material_prices mp ON m.id = mp.material_id
AND mp.is_active = 1
AND mp.id = (
SELECT id
FROM material_prices
WHERE material_id = m.id
AND is_active = 1
ORDER BY valid_from DESC, id DESC
LIMIT 1
)
LEFT JOIN bygma_materials_mapping bmm ON m.id = bmm.material_id
LEFT JOIN bygma_products bp ON bmm.bygma_product_id = bp.id
AND bp.is_active = 1
${whereClause}
ORDER BY m.name ASC
LIMIT ? OFFSET ?`,
[...params, parseInt(limit), parseInt(offset)]
);
res.json({
success: true,
materials: materials || [],
count: materials?.length || 0
});
} catch (error) {
logger.error('Error fetching materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialer',
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'
});
}
});
// Material search endpoint for InlineSmartPackage
app.get('/api/materials/search', async (req, res) => {
try {
const { q } = req.query;
if (!q || q.length < 2) {
return res.json({
success: true,
materials: [],
count: 0
});
}
const searchTerm = `%${q}%`;
const rentalNoiseFilter = `
AND LOWER(tekst) NOT LIKE '%minigraver%'
AND LOWER(tekst) NOT LIKE '%dumper%'
AND LOWER(tekst) NOT LIKE '%trailer%'
AND LOWER(tekst) NOT LIKE '%lift%'
AND LOWER(tekst) NOT LIKE '%stillads%'
AND LOWER(tekst) NOT LIKE '%udlej%'
AND LOWER(tekst) NOT LIKE '%leje%'
`;
const bygmaMaterials = await databaseService.query(
`SELECT
id,
vareNr as varenummer,
tekst as product_name,
current_netto_pris as price,
enhed as unit,
'Bygma' as supplier,
varegrp as category
FROM bygma_products
WHERE is_active = 1
AND (tekst LIKE ? OR vareNr LIKE ? OR varegrp LIKE ?)
${rentalNoiseFilter}
ORDER BY tekst ASC
LIMIT 100`,
[searchTerm, searchTerm, searchTerm]
);
const starkMaterials = await databaseService.query(
`SELECT
id,
product_id as varenummer,
product_name,
price,
unit,
'Stark A/S' as supplier,
category
FROM stark_materials_cache
WHERE (product_name LIKE ? OR product_id LIKE ? OR category LIKE ?)
AND LOWER(product_name) NOT LIKE '%minigraver%'
AND LOWER(product_name) NOT LIKE '%dumper%'
AND LOWER(product_name) NOT LIKE '%trailer%'
AND LOWER(product_name) NOT LIKE '%lift%'
AND LOWER(product_name) NOT LIKE '%stillads%'
AND LOWER(product_name) NOT LIKE '%udlej%'
AND LOWER(product_name) NOT LIKE '%leje%'
ORDER BY product_name ASC
LIMIT 100`,
[searchTerm, searchTerm, searchTerm]
);
const materials = [...bygmaMaterials, ...starkMaterials];
res.json({
success: true,
materials: materials || [],
count: materials?.length || 0
});
} catch (error) {
logger.error('Error searching materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning af materialer',
details: error.message
});
}
});
app.get('/api/renting/search', async (req, res) => {
try {
const { q } = req.query;
if (!q || q.length < 2) {
return res.json({
success: true,
rentals: [],
count: 0
});
}
const searchTerm = `%${q}%`;
const normalizedSearchTerm = `%${String(q).toLowerCase().replace(/[(),]/g, ' ').replace(/\s+/g, ' ').trim()}%`;
const rentalFilterBygma = `
(
LOWER(tekst) LIKE '%minigraver%'
OR LOWER(tekst) LIKE '%dumper%'
OR LOWER(tekst) LIKE '%trailer%'
OR LOWER(tekst) LIKE '%lift%'
OR LOWER(tekst) LIKE '%stillads%'
OR LOWER(tekst) LIKE '%udlej%'
OR LOWER(tekst) LIKE '%leje%'
)
`;
const bygmaRentals = await databaseService.query(
`SELECT
id,
vareNr as varenummer,
tekst as product_name,
current_netto_pris as price,
enhed as unit,
'Bygma' as supplier,
varegrp as category
FROM bygma_products
WHERE is_active = 1
AND (
tekst LIKE ? OR vareNr LIKE ? OR varegrp LIKE ?
OR LOWER(REPLACE(REPLACE(REPLACE(tekst, '(', ' '), ')', ' '), ',', ' ')) LIKE ?
)
AND ${rentalFilterBygma}
ORDER BY tekst ASC
LIMIT 100`,
[searchTerm, searchTerm, searchTerm, normalizedSearchTerm]
);
const starkRentals = await databaseService.query(
`SELECT
id,
product_id as varenummer,
product_name,
price,
brutto_price,
unit,
'Stark A/S' as supplier,
category,
JSON_UNQUOTE(JSON_EXTRACT(supplier_info, '$.source')) as source
FROM stark_materials_cache
WHERE (
product_name LIKE ? OR product_id LIKE ? OR category LIKE ?
OR LOWER(REPLACE(REPLACE(REPLACE(product_name, '(', ' '), ')', ' '), ',', ' ')) LIKE ?
)
AND (
LOWER(product_name) LIKE '%minigraver%'
OR LOWER(product_name) LIKE '%dumper%'
OR LOWER(product_name) LIKE '%trailer%'
OR LOWER(product_name) LIKE '%lift%'
OR LOWER(product_name) LIKE '%stillads%'
OR LOWER(product_name) LIKE '%udlej%'
OR LOWER(product_name) LIKE '%leje%'
OR LOWER(category) LIKE '%udlej%'
OR LOWER(category) LIKE '%rental%'
OR LOWER(category) LIKE '%leje%'
)
ORDER BY
CASE
WHEN JSON_UNQUOTE(JSON_EXTRACT(supplier_info, '$.source')) = 'materiel_xlsx' THEN 0
ELSE 1
END ASC,
product_name ASC
LIMIT 200`,
[searchTerm, searchTerm, searchTerm, normalizedSearchTerm]
);
const rentals = [...bygmaRentals, ...starkRentals];
res.json({
success: true,
rentals,
count: rentals.length
});
} catch (error) {
logger.error('Error searching rentals:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning af udlejning',
details: error.message
});
}
});
// Task search endpoint for InlineSmartPackage
app.get('/api/tasks/search', async (req, res) => {
try {
const { q, category } = req.query;
if (!q || q.length < 2) {
return res.json({
success: true,
tasks: [],
count: 0
});
}
const searchTerm = `%${q}%`;
let query = `
SELECT
id,
role,
task_description as name,
unit,
category,
subcategory,
'labor_tasks' as source
FROM labor_tasks
WHERE (task_description LIKE ? OR category LIKE ? OR subcategory LIKE ?)
`;
const params = [searchTerm, searchTerm, searchTerm];
if (category) {
query += ' AND category = ?';
params.push(category);
}
query += ' ORDER BY task_description ASC LIMIT 50';
const tasks = await databaseService.query(query, params);
res.json({
success: true,
tasks: tasks || [],
count: tasks?.length || 0
});
} catch (error) {
logger.error('Error searching tasks:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning af opgaver',
details: error.message
});
}
});
// Get all task categories
app.get('/api/tasks/categories', async (req, res) => {
try {
const categories = await databaseService.query(
`SELECT DISTINCT category, COUNT(*) as task_count
FROM labor_tasks
GROUP BY category
ORDER BY category ASC`
);
res.json({
success: true,
categories: categories || [],
count: categories?.length || 0
});
} catch (error) {
logger.error('Error fetching task categories:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af kategorier'
});
}
});
// 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 {
// Check if API key is configured
if (!process.env.ORDRESTATUS_API_KEY) {
return res.json({
success: true,
orders: [],
message: 'Order status API not configured'
});
}
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 {
// Check if API key is configured
if (!process.env.ORDRESTATUS_API_KEY) {
return res.json({
success: true,
statusSummary: [],
message: 'Order status API not configured'
});
}
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 importService = new PriceImportServiceClass(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 {
// Try to get material categories from ordrestyring database first
let materialCategories = [];
let projectTypes = [];
try {
// Create connection to ordrestyring database
const mysql = require('mysql2/promise');
const ordrestyringDb = mysql.createPool({
host: process.env.ORDRESTYRING_DB_HOST,
user: process.env.ORDRESTYRING_DB_USER,
password: process.env.ORDRESTYRING_DB_PASSWORD,
database: process.env.ORDRESTYRING_DB_NAME,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0
});
// Get material categories from case_materials in ordrestyring database
const [materialRows] = await ordrestyringDb.execute(`
SELECT DISTINCT
CASE
WHEN product_text LIKE '%tag%' OR product_text LIKE '%Tag%' THEN 'Tag/Tagmaterialer'
WHEN product_text LIKE '%gulv%' OR product_text LIKE '%Gulv%' THEN 'Gulv'
WHEN product_text LIKE '%vand%' OR product_text LIKE '%Vand%' OR product_text LIKE '%VVS%' THEN 'VVS'
WHEN product_text LIKE '%el%' OR product_text LIKE '%El%' OR product_text LIKE '%elektrik%' THEN 'Elektrik'
WHEN product_text LIKE '%træ%' OR product_text LIKE '%Træ%' OR product_text LIKE '%timber%' THEN 'Træ'
WHEN product_text LIKE '%mur%' OR product_text LIKE '%Mur%' OR product_text LIKE '%sten%' THEN 'Murværk'
WHEN product_text LIKE '%isolering%' OR product_text LIKE '%Isolering%' THEN 'Isolering'
WHEN product_text LIKE '%vinduer%' OR product_text LIKE '%døre%' THEN 'Vinduer/Døre'
ELSE 'Diverse'
END as category,
COUNT(*) as count
FROM case_materials
WHERE product_text IS NOT NULL AND product_text != ''
GROUP BY category
ORDER BY count DESC, category ASC
`);
// Get project types from cases table in ordrestyring database
const [projectRows] = await ordrestyringDb.execute(`
SELECT DISTINCT
case_type as type,
COUNT(*) as count
FROM cases
WHERE case_type IS NOT NULL AND case_type != ''
GROUP BY case_type
ORDER BY count DESC, case_type ASC
LIMIT 20
`);
materialCategories = materialRows.map(cat => ({
category: cat.category,
count: cat.count
}));
projectTypes = projectRows.map(type => ({
type: type.type,
count: type.count
}));
// Close ordrestyring database connection
await ordrestyringDb.end();
} catch (ordrestyringError) {
console.error('Error fetching from ordrestyring database:', ordrestyringError);
// Fallback to default categories if ordrestyring database fails
materialCategories = [
{ category: 'Tag/Tagmaterialer', count: 0 },
{ category: 'VVS', count: 0 },
{ category: 'Elektrik', count: 0 },
{ category: 'Træ', count: 0 },
{ category: 'Diverse', count: 0 }
];
projectTypes = [
{ type: 'renovation', count: 0 },
{ type: 'nybygning', count: 0 },
{ type: 'reparation', count: 0 }
];
}
res.json({
success: true,
materialCategories: materialCategories,
projectTypes: projectTypes
});
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({
success: false,
message: 'Error fetching categories',
categories: {
materialCategories: [
{ category: 'Tag/Tagmaterialer', count: 0 },
{ category: 'VVS', count: 0 },
{ category: 'Elektrik', count: 0 },
{ category: 'Træ', count: 0 },
{ category: 'Diverse', count: 0 }
],
projectTypes: [
{ type: 'renovation', count: 0 },
{ type: 'nybygning', count: 0 },
{ type: 'reparation', count: 0 }
]
}
});
}
});
// 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) {
logError('ADD_MATERIAL_PRICE', error, {
materialName: name || 'Unknown',
category: category || 'Unknown',
price: unit_price || 0,
endpoint: '/api/categories/materials'
});
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()
});
});
// Scrape installation manual by varenr
app.post('/api/pricing/scrape-manual', async (req, res) => {
const { varenr } = req.body;
if (!varenr) {
return res.status(400).json({
success: false,
error: 'VareNr er påkrævet'
});
}
console.log(`📚 Scraping installation manual for VareNr: ${varenr}`);
try {
const { spawn } = require('child_process');
const path = require('path');
// Path to Python script
const scriptPath = path.join(__dirname, 'scrape_manual_by_varenr.py');
// Set up environment with OpenAI API key
const env = { ...process.env };
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY ikke konfigureret');
}
const python = spawn('python3', [scriptPath, varenr], { env });
let output = '';
let errorOutput = '';
python.stdout.on('data', (data) => {
output += data.toString();
});
python.stderr.on('data', (data) => {
errorOutput += data.toString();
});
python.on('close', (code) => {
if (code === 0) {
console.log(`✅ Manual scraped successfully for ${varenr}`);
res.json({
success: true,
varenr,
output,
message: 'Montagevejledning hentet og gemt'
});
} else {
console.error(`❌ Scraper failed with code ${code}`);
res.status(500).json({
success: false,
error: 'Scraping fejlede',
output,
errorOutput
});
}
});
} catch (error) {
console.error('Scrape manual error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Check for missing installation manuals
app.get('/api/installation-manuals/check-missing', async (req, res) => {
try {
const { projectId } = req.query;
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
let query = `
SELECT DISTINCT pm.varenr, pm.material_name
FROM project_materials pm
LEFT JOIN installation_manuals im ON pm.varenr = im.varenr
WHERE pm.varenr IS NOT NULL
AND pm.varenr != ''
AND im.varenr IS NULL
`;
const params = [];
if (projectId) {
query += ' AND pm.project_id = ?';
params.push(projectId);
}
query += ' ORDER BY pm.material_name LIMIT 50';
const [missing] = await databaseService.pool.execute(query, params);
res.json({
success: true,
count: missing.length,
projectId: projectId || null,
missing: missing.map(m => ({
varenr: m.varenr,
materialName: m.material_name
}))
});
} catch (error) {
console.error('Error checking missing manuals:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tjek af manglende vejledninger',
details: error.message
});
}
});
// Get installation manual for a specific varenr
app.get('/api/installation-manuals/:varenr', async (req, res) => {
try {
const { varenr } = req.params;
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
const [rows] = await databaseService.pool.execute(
'SELECT * FROM installation_manuals WHERE varenr = ?',
[varenr]
);
if (rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Ingen montagevejledning fundet for dette varenummer'
});
}
const manual = rows[0];
// Parse JSON fields
if (typeof manual.installation_steps === 'string') {
try {
manual.installation_steps = JSON.parse(manual.installation_steps);
} catch (e) {
manual.installation_steps = [];
}
}
if (typeof manual.key_points === 'string') {
try {
manual.key_points = JSON.parse(manual.key_points);
} catch (e) {
manual.key_points = [];
}
}
if (typeof manual.required_tools === 'string') {
try {
manual.required_tools = JSON.parse(manual.required_tools);
} catch (e) {
manual.required_tools = [];
}
}
res.json({
success: true,
manual
});
} catch (error) {
console.error('Error getting manual:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af montagevejledning',
details: error.message
});
}
});
// Check if Bygma product has installation manual (quick check without downloading)
app.post('/api/installation-manuals/check-availability', async (req, res) => {
try {
const { productUrl } = req.body;
if (!productUrl) {
return res.status(400).json({
success: false,
error: 'productUrl er påkrævet'
});
}
console.log('📋 Checking manual availability for:', productUrl);
const scriptPath = path.join(__dirname, 'scrape_bygma_api.py');
return new Promise((resolve, reject) => {
const process = spawn('python3', [scriptPath, productUrl, '--check-only']);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code !== 0) {
console.error('❌ Check availability script error:', stderr);
return res.status(500).json({
success: false,
error: 'Fejl ved tjek af manual tilgængelighed',
details: stderr
});
}
try {
const result = JSON.parse(stdout);
console.log('✅ Manual check result:', result);
res.json({
success: true,
productUrl,
hasManual: result.has_manual,
manualCount: result.manual_count || 0,
manuals: result.manuals || []
});
} catch (error) {
console.error('❌ Failed to parse check result:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke parse resultat fra manual check',
details: error.message
});
}
});
});
} catch (error) {
console.error('Check manual availability error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Get project materials with manual status
app.get('/api/project-materials/:projectId', async (req, res) => {
try {
const { projectId } = req.params;
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
const [materials] = await databaseService.pool.execute(`
SELECT
pm.*,
CASE WHEN im.varenr IS NOT NULL THEN 1 ELSE 0 END as has_manual,
im.varenr as manual_varenr
FROM project_materials pm
LEFT JOIN installation_manuals im ON pm.varenr = im.varenr
WHERE pm.project_id = ?
ORDER BY pm.material_name
`, [projectId]);
res.json({
success: true,
projectId: parseInt(projectId),
materials: materials.map(m => ({
id: m.id,
projectId: m.project_id,
varenr: m.varenr,
materialName: m.material_name,
productName: m.product_name,
quantity: m.quantity,
unit: m.unit,
unitPrice: m.unit_price,
totalPrice: m.total_price,
hasManual: Boolean(m.has_manual)
})),
count: materials.length,
withManuals: materials.filter(m => m.has_manual).length,
withoutManuals: materials.filter(m => !m.has_manual).length
});
} catch (error) {
console.error('Error getting project materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projektmaterialer',
details: error.message
});
}
});
// Bulk fetch missing manuals for a project
app.post('/api/installation-manuals/fetch-missing', async (req, res) => {
try {
const { projectId, varenrs } = req.body;
if (!projectId && !varenrs) {
return res.status(400).json({
success: false,
error: 'Enten projectId eller varenrs array er påkrævet'
});
}
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke tilgængelig'
});
}
let missingVarenrs = [];
if (varenrs && Array.isArray(varenrs)) {
missingVarenrs = varenrs;
} else if (projectId) {
// Get missing varenrs for project
const [missing] = await databaseService.pool.execute(`
SELECT DISTINCT pm.varenr
FROM project_materials pm
LEFT JOIN installation_manuals im ON pm.varenr = im.varenr
WHERE pm.project_id = ?
AND pm.varenr IS NOT NULL
AND pm.varenr != ''
AND im.varenr IS NULL
`, [projectId]);
missingVarenrs = missing.map(m => m.varenr);
}
if (missingVarenrs.length === 0) {
return res.json({
success: true,
message: 'Ingen manglende vejledninger',
results: []
});
}
// Process each varenr (limit to prevent overload)
const limit = Math.min(missingVarenrs.length, 10);
const results = [];
for (let i = 0; i < limit; i++) {
const varenr = missingVarenrs[i];
try {
// Call scrape-manual endpoint internally
const { spawn } = require('child_process');
const path = require('path');
const scriptPath = path.join(__dirname, 'scrape_manual_by_varenr.py');
const env = { ...process.env };
if (!env.OPENAI_API_KEY) {
results.push({ varenr, success: false, error: 'OPENAI_API_KEY mangler' });
continue;
}
await new Promise((resolve) => {
const python = spawn('python3', [scriptPath, varenr], { env });
let output = '';
let errorOutput = '';
python.stdout.on('data', (data) => {
output += data.toString();
});
python.stderr.on('data', (data) => {
errorOutput += data.toString();
});
python.on('close', (code) => {
if (code === 0) {
console.log(`✅ Manual fetched for ${varenr}`);
results.push({ varenr, success: true, message: 'Hentet succesfuldt' });
} else {
console.error(`❌ Failed for ${varenr}`);
results.push({ varenr, success: false, error: errorOutput || 'Scraping fejlede' });
}
resolve();
});
python.on('error', (error) => {
results.push({ varenr, success: false, error: error.message });
resolve();
});
});
// Add delay to avoid rate limiting
if (i < limit - 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
results.push({ varenr, success: false, error: error.message });
}
}
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
res.json({
success: true,
message: `Behandlet ${results.length} af ${missingVarenrs.length} manglende vejledninger`,
successful,
failed,
remaining: missingVarenrs.length - limit,
results
});
} catch (error) {
console.error('Error fetching missing manuals:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af manglende vejledninger',
details: error.message
});
}
});
// 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()
});
});
// Static files will be served after API routes - moved to end of file
// Tag experience suggestions - must come before parametrized routes
app.get('/api/customer-projects/tag-experience-suggestions', verifyToken, requireConfiguredProjectOperator, async (req, res) => {
try {
console.log('🏷️ Fetching tag experience suggestions from Ordrestyring database...');
let laborSuggestions = [];
// Hent faktiske tag-cases fra Ordrestyring database
try {
console.log('📊 Querying ordrestyring_local database for tag cases...');
// Use the existing databaseService with cross-database query
const tagCases = await databaseService.query(`
SELECT
c.case_number,
c.description,
c.work_done,
c.remarks
FROM ordrestyring_local.cases c
WHERE c.description REGEXP 'tag|eternit|tegl|rende|velux|rygning'
AND c.case_number IS NOT NULL
AND c.description IS NOT NULL
AND c.description != ''
ORDER BY c.creation_date DESC
LIMIT 15
`);
console.log(`📋 Found ${tagCases.length} actual tag cases from Ordrestyring`);
// Transform Ordrestyring cases til labor suggestions
for (const tagCase of tagCases) {
const hours = 12; // Default reasonable hours for tag work
const hourlyRate = 550; // Standard rate
const totalCost = hours * hourlyRate;
// Extract main task from description
let taskDescription = tagCase.description;
if (taskDescription && taskDescription.length > 100) {
taskDescription = taskDescription.substring(0, 97) + '...';
}
laborSuggestions.push({
task_description: taskDescription || 'Tag arbejde',
estimated_hours: hours,
hourly_rate: hourlyRate,
total_cost: totalCost,
category: 'tag_arbejde',
confidence: 0.8, // Good confidence - real case data
source: 'ordrestyring_database',
case_number: tagCase.case_number,
work_notes: tagCase.work_done || tagCase.remarks || 'Ingen noter tilgængelige'
});
}
console.log(`✅ Converted ${laborSuggestions.length} cases to labor suggestions`);
} catch (dbError) {
console.error('❌ Database error when fetching tag cases:', dbError);
// Continue to fallback
}
// Final fallback to default suggestions
if (laborSuggestions.length === 0) {
console.log('🔄 Using default tag suggestions');
laborSuggestions = [
{
task_description: 'Skift af B7 eternitplader - standard tag',
estimated_hours: 16,
hourly_rate: 550,
total_cost: 8800,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Reparation af tagrender og nedløb',
estimated_hours: 8,
hourly_rate: 500,
total_cost: 4000,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Montering af Velux ovenlysvindue',
estimated_hours: 6,
hourly_rate: 580,
total_cost: 3480,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
}
];
}
console.log(`📋 Returning ${laborSuggestions.length} tag experience suggestions`);
res.json({
success: true,
suggestions: laborSuggestions,
message: `Fundet ${laborSuggestions.length} erfaringer fra tidligere tag-opgaver`,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error fetching tag experience suggestions:', error);
// Fallback to default suggestions if everything fails
const defaultSuggestions = [
{
task_description: 'Skift af B7 eternitplader - standard tag',
estimated_hours: 16,
hourly_rate: 550,
total_cost: 8800,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Reparation af tagrender og nedløb',
estimated_hours: 8,
hourly_rate: 500,
total_cost: 4000,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Montering af Velux ovenlysvindue',
estimated_hours: 6,
hourly_rate: 580,
total_cost: 3480,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
}
];
res.json({
success: true,
suggestions: defaultSuggestions,
message: 'Viser standard tag-opgave skabeloner (historiske data ikke tilgængelige)',
timestamp: new Date().toISOString()
});
}
});
// Tag Geometry Intelligence - Smart estimater baseret på lokal Ordrestyring database
app.get('/api/tag-geometry-estimates', async (req, res) => {
try {
const { area, tagType, complexity, taghældning, adgangsforhold } = req.query;
try {
requireRoofTypeEstimate(tagType);
} catch (_error) {
return res.status(400).json({
success: false,
code: 'ROOF_TYPE_UNSUPPORTED',
error: `Unsupported roof type: ${tagType}`
});
}
console.log(`🏗️ Smart tag estimering for: ${area}m², type: ${tagType}, kompleksitet: ${complexity}`);
if (!LocalOrdrestyringAnalyzerClass) {
return res.json({
success: true,
source: 'fallback_unavailable',
total_estimated_hours: 0,
confidence: 0,
work_breakdown: [],
similar_cases: [],
analysis: {
based_on_cases: 0,
database_analysis: 'Local Ordrestyring analyzer not available'
},
metadata: {
generated_at: new Date().toISOString(),
parameters: { area, tagType, complexity, taghældning, adgangsforhold },
data_source: 'fallback'
}
});
}
// Load the local database analyzer
const analyzer = new LocalOrdrestyringAnalyzerClass();
// Build description from parameters for better matching
const description = `${tagType || 'Tag'} arbejde på ${area || '100'}m² med ${complexity === '1' ? 'normal' : complexity === '2' ? 'høj' : 'standard'} kompleksitet`;
console.log(`🔍 Analyzing with description: "${description}"`);
try {
await analyzer.connect();
// Get smart estimate from local database
const estimate = await analyzer.generateSmartEstimate(description, { area, tagType, complexity });
if (estimate.success) {
console.log(`✅ Database analysis successful: ${estimate.confidence}% confidence, ${estimate.estimatedHours}h estimated`);
// Apply area scaling if we have a base area
const baseArea = parseFloat(area) || 100;
const complexityFactor = parseFloat(complexity) || 1.0;
const scaledHours = Math.round(estimate.estimatedHours * complexityFactor);
// Convert work phases to our expected format
const workBreakdown = estimate.workPhases.map(phase => ({
category: phase.phase,
description: `${phase.phase} (baseret på ${phase.basedOnEntries || 'lignende'} cases)`,
hours: Math.round(phase.hours * complexityFactor),
based_on: `Analyse af ${estimate.basedOnCases} lignende cases fra Ordrestyring`,
confidence: estimate.confidence / 100,
original_hours: phase.hours
}));
// Add area-specific adjustments if no work breakdown available
if (workBreakdown.length === 0) {
workBreakdown.push({
category: 'Hovedarbejde',
description: `${tagType || 'Tag'} arbejde baseret på lignende projekter`,
hours: scaledHours,
based_on: `Analyse af ${estimate.basedOnCases} lignende cases`,
confidence: estimate.confidence / 100
});
}
await analyzer.disconnect();
return res.json({
success: true,
source: 'local_database',
total_estimated_hours: scaledHours,
confidence: estimate.confidence,
work_breakdown: workBreakdown,
similar_cases: estimate.similarCases.slice(0, 8), // Top 8 similar cases for tag work
analysis: {
based_on_cases: estimate.basedOnCases,
database_analysis: estimate.analysis,
scaling_factors: {
area: baseArea,
complexity: complexityFactor,
applied: scaledHours !== estimate.estimatedHours
}
},
metadata: {
generated_at: new Date().toISOString(),
parameters: { area, tagType, complexity, taghældning, adgangsforhold },
data_source: 'Ordrestyring API (local database)',
algorithm: 'similarity_matching_with_work_breakdown'
}
});
} else {
console.log(`⚠️ Database analysis failed, falling back to generic estimates: ${estimate.message}`);
}
await analyzer.disconnect();
} catch (dbError) {
console.error('❌ Database analysis error:', dbError.message);
try {
await analyzer.disconnect();
} catch (disconnectError) {
// Ignore disconnect errors
}
}
// If database analysis fails, provide intelligent fallback based on tag type and area
console.log('⚠️ Database analysis failed - providing intelligent fallback based on parameters');
const baseArea = parseFloat(area) || 100;
const complexityFactor = parseFloat(complexity) || 1.0;
const { hoursPerM2, estimatedHours } = estimateCanonicalRoofLabor(
baseArea,
complexityFactor,
tagType
);
const roofTypeLabel = CANONICAL_ROOF_TYPE_OPTIONS.find(option => option.value === tagType).label;
// Create work breakdown based on tag type
const workBreakdown = [
{
category: 'Forberedelse',
description: 'Opstilling og sikkerhed',
hours: Math.round(estimatedHours * 0.15),
based_on: 'Standard tidsfordeling for tag arbejde',
confidence: 0.6
},
{
category: 'Hovedarbejde',
description: `${roofTypeLabel} arbejde`,
hours: Math.round(estimatedHours * 0.60),
based_on: 'Estimat baseret på areal og kompleksitet',
confidence: 0.6
},
{
category: 'Afslutning',
description: 'Rengøring og bortkørsel',
hours: Math.round(estimatedHours * 0.25),
based_on: 'Standard afslutningsarbejde',
confidence: 0.6
}
];
res.json({
success: true,
source: 'intelligent_fallback',
total_estimated_hours: estimatedHours,
confidence: 60,
work_breakdown: workBreakdown,
similar_cases: [],
analysis: {
based_on_cases: 0,
fallback_method: 'area_and_type_calculation',
scaling_factors: {
area: baseArea,
hours_per_m2: hoursPerM2,
complexity: complexityFactor,
total_calculation: `${baseArea}m² × ${hoursPerM2} timer/m² × ${complexityFactor} kompleksitet = ${estimatedHours} timer`
}
},
metadata: {
generated_at: new Date().toISOString(),
parameters: { area, tagType, complexity, taghældning, adgangsforhold },
data_source: 'Intelligent fallback (industry standards)',
algorithm: 'area_based_estimation',
note: 'Database analysis ikke tilgængelig - bruger standard estimater'
}
});
} catch (error) {
console.error('❌ Error in tag geometry estimation:', error);
res.status(500).json({
success: false,
error: 'Fejl ved beregning af tag estimat',
message: 'Der opstod en fejl ved beregning af smart tag estimat'
});
}
});
// Customer Projects API Routes
app.use('/api/customer-projects', require('./src/routes/customerProjects'));
app.use('/api/ai', require('./src/routes/ai'));
app.use('/api/users', require('./src/routes/users'));
// Mobile field workflow backed by read-only Ordrestyring history and local Tilbudsgiver intake data
app.use('/api/mobile', require('./src/routes/mobileOrders'));
// Analytics API Routes
app.use('/api/analytics', require('./src/routes/analytics'));
app.use('/api/product-telemetry', require('./src/routes/productTelemetry'));
const optionalAdminApiKeyGuard = (req, res, next) => {
const requiredKey = process.env.ADMIN_API_KEY;
if (!requiredKey) {
return next();
}
const providedKey = req.headers['x-admin-api-key'] || req.query.adminApiKey;
if (providedKey === requiredKey) {
return next();
}
return res.status(401).json({
success: false,
error: 'Unauthorized admin route'
});
};
// Ordrestyring API Routes
app.use('/api/ordrestyring', require('./routes/ordrestyring'));
// Intentional production-only mutation boundary. The offers router itself is
// fail-closed; only this application bootstrap injects the live transport.
const { createOffersRouter } = require('./routes/offers');
const { createProductionOfferClient } = require('./src/graphql/mutations/createOffer');
const productionOfferTransport = {
request(...args) {
if (!graphqlClient || typeof graphqlClient.request !== 'function') {
const error = new Error('Ordrestyring production transport is not initialized');
error.status = 503;
error.code = 'ORDRESTYRING_TRANSPORT_NOT_INITIALIZED';
throw error;
}
return graphqlClient.request(...args);
}
};
app.use('/api/ordrestyring/offers', createOffersRouter({
graphqlClient: createProductionOfferClient(productionOfferTransport)
}));
// Calendar/Hours API Routes (GraphQL)
app.use('/api/calendar', require('./routes/calendar'));
// Cases API Routes (GraphQL) - Must be before old /api/ordrestyring/case/:caseNumber endpoint
app.use('/api/ordrestyring/case', require('./routes/cases'));
// Visual AI Test Reports API Routes
app.use('/api/visual-reports', optionalAdminApiKeyGuard, require('./routes/visualTestReports'));
// Admin Logs API Routes
app.use('/api/admin/logs', optionalAdminApiKeyGuard, require('./routes/adminLogs'));
// Health Dashboard API Routes
app.use('/api/health', require('./src/routes/healthDashboard'));
// Client Error Logging Routes (for frontend to report errors)
app.use('/api/client-errors', require('./routes/clientErrors'));
// Support Tickets (osTicket)
app.use('/api/support', require('./src/routes/supportTickets'));
// 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;
let bygmaCount = 0;
let starkCount = 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] : [])
);
bygmaCount = countResult.total;
} catch (e) {
console.error('Bygma count query error:', e);
bygmaCount = 0;
}
// Get total count for STARK
try {
let starkCountQuery = `SELECT COUNT(*) as total FROM stark_materials_cache`;
const starkCountParams = [];
if (category && category !== 'alle') {
starkCountQuery += ' WHERE category = ?';
starkCountParams.push(category);
} else if (search) {
starkCountQuery += ' WHERE (product_name LIKE ? OR product_id LIKE ?)';
starkCountParams.push(`%${search}%`, `%${search}%`);
} else if (category && category !== 'alle' && search) {
starkCountQuery += ' WHERE category = ? AND (product_name LIKE ? OR product_id LIKE ?)';
starkCountParams.push(category, `%${search}%`, `%${search}%`);
}
const [starkResult] = await databaseService.query(starkCountQuery, starkCountParams);
starkCount = starkResult.total;
} catch (e) {
console.error('STARK count query error:', e);
starkCount = 0;
}
totalCount = bygmaCount + starkCount;
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 STARK materialer (supplement)
const remainingSlotsForStark = Math.max(0, limit - allMaterials.length);
if (remainingSlotsForStark > 0) {
let starkQuery = `
SELECT
id,
product_id as product_code,
product_name as name,
category,
unit,
price as unit_price,
'Stark A/S' as supplier_name,
'stark' as source,
last_updated,
product_id as description
FROM stark_materials_cache
`;
const starkParams = [];
if (category && category !== 'alle') {
starkQuery += ' WHERE category = ?';
starkParams.push(category);
} else if (search) {
starkQuery += ' WHERE (product_name LIKE ? OR product_id LIKE ?)';
starkParams.push(`%${search}%`, `%${search}%`);
} else if (category && category !== 'alle' && search) {
starkQuery += ' WHERE category = ? AND (product_name LIKE ? OR product_id LIKE ?)';
starkParams.push(category, `%${search}%`, `%${search}%`);
}
starkQuery += ` ORDER BY product_name LIMIT ? OFFSET ?`;
starkParams.push(remainingSlotsForStark, 0);
try {
const starkMaterials = await databaseService.query(starkQuery, starkParams);
const processedStark = starkMaterials.map(material => ({
id: `stark_${material.id}`,
name: material.name || 'Ukendt Stark produkt',
category: material.category || 'øvrige',
unit: material.unit || 'stk',
unit_price: parseFloat(material.unit_price || 0),
supplier_name: 'Stark A/S',
description: material.description || '',
source: 'stark',
product_code: material.product_code,
canEdit: false // STARK produkter kan ikke redigeres
}));
allMaterials = allMaterials.concat(processedStark);
} catch (e) {
console.error('STARK query error:', e);
}
}
// 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,
stark_products: allMaterials.filter(m => m.source === 'stark').length,
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
}));
// Tilføj STARK materialer til cache
let cacheStarkQuery = `
SELECT
id,
product_id as product_code,
product_name as name,
category,
unit,
price as unit_price,
'Stark A/S' as supplier_name,
'stark' as source,
last_updated
FROM stark_materials_cache
ORDER BY product_name
`;
const starkCacheMaterials = await databaseService.query(cacheStarkQuery);
const processedStarkCache = starkCacheMaterials.map(material => ({
id: `stark_${material.id}`,
name: material.name || 'Ukendt Stark produkt',
category: material.category || 'øvrige',
unit: material.unit || 'stk',
unit_price: parseFloat(material.unit_price || 0),
supplier_name: 'Stark A/S',
description: material.product_code || '',
source: 'stark',
product_code: material.product_code,
canEdit: false
}));
const allCacheData = processedCacheMaterials.concat(processedStarkCache);
materialsCache.set({
materials: allCacheData,
totalCount: allCacheData.length,
source_info: {
bygma_products: processedCacheMaterials.length,
stark_products: processedStarkCache.length,
builtin_products: 0,
total_in_cache: allCacheData.length
}
});
console.log(`✅ Cache built with ${allCacheData.length} materials (Bygma: ${processedCacheMaterials.length}, STARK: ${processedStarkCache.length})`);
} 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'
});
}
});
// Note: Duplicate POST /api/pricing/materials endpoint removed to avoid conflicts
// The main implementation is kept earlier in the file
// 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'
});
}
});
// ========================================
// MATERIAL PACKAGES API ENDPOINTS
// ========================================
// Create material package
app.post('/api/material-packages', async (req, res) => {
try {
const { name, description, category, materials, createdBy } = req.body;
if (!name || !materials || !Array.isArray(materials) || materials.length === 0) {
return res.status(400).json({
success: false,
error: 'Navn og materialer er påkrævet'
});
}
const result = await materialPackageService.createPackage({
name,
description,
category,
materials,
createdBy: createdBy || 'system'
});
res.json({
success: true,
package: result,
message: 'Materiale pakke oprettet succesfuldt'
});
} catch (error) {
console.error('Error creating material package:', error);
res.status(500).json({
success: false,
error: 'Fejl ved oprettelse af materiale pakke: ' + error.message
});
}
});
// Get all material packages
app.get('/api/material-packages', async (req, res) => {
try {
// Check if service is initialized
if (!materialPackageService) {
return res.status(503).json({
success: false,
error: 'Material package service not initialized'
});
}
const { category, search, limit } = req.query;
const packages = await materialPackageService.getPackages({
category,
search,
limit
});
res.json({
success: true,
packages
});
} catch (error) {
console.error('Error getting material packages:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materiale pakker: ' + error.message
});
}
});
// Get package details
app.get('/api/material-packages/:id', async (req, res) => {
try {
if (!materialPackageService) {
return res.status(503).json({
success: false,
error: 'Material package service not initialized'
});
}
const { id } = req.params;
const packageDetails = await materialPackageService.getPackageDetails(id);
res.json({
success: true,
package: packageDetails
});
} catch (error) {
console.error('Error getting package details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af pakke detaljer: ' + error.message
});
}
});
// Update material package
app.put('/api/material-packages/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, description, category } = req.body;
await materialPackageService.updatePackage(id, {
name,
description,
category
});
res.json({
success: true,
message: 'Materiale pakke opdateret succesfuldt'
});
} catch (error) {
console.error('Error updating material package:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materiale pakke: ' + error.message
});
}
});
// Delete material package
app.delete('/api/material-packages/:id', async (req, res) => {
try {
const { id } = req.params;
await materialPackageService.deletePackage(id);
res.json({
success: true,
message: 'Materiale pakke slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting material package:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materiale pakke: ' + error.message
});
}
});
// Add package to project
app.post('/api/projects/:projectId/add-package/:packageId', async (req, res) => {
try {
const { projectId, packageId } = req.params;
const { multiplier = 1 } = req.body;
const result = await materialPackageService.addPackageToProject(
projectId,
packageId,
{ multiplier }
);
res.json({
success: true,
...result,
message: 'Materiale pakke tilføjet til projekt succesfuldt'
});
} catch (error) {
console.error('Error adding package to project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af pakke til projekt: ' + error.message
});
}
});
// Get Bygma standard categories
app.get('/api/material-categories/bygma', async (req, res) => {
try {
const categories = await materialPackageService.getBygmaCategories();
res.json({
success: true,
categories
});
} catch (error) {
console.error('Error getting Bygma categories:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af Bygma kategorier: ' + error.message
});
}
});
// 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' });
}
});
// Get roof types for geometry calculations
app.get('/api/geometry/roof-types', async (req, res) => {
try {
res.json({
success: true,
data: CANONICAL_ROOF_TYPE_OPTIONS
});
} catch (error) {
console.error('Error getting roof types:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tagtyper'
});
}
});
// Get access levels for geometry calculations
app.get('/api/geometry/access-levels', async (req, res) => {
try {
const accessLevels = [
{ value: 'let', label: 'Let adgang', factor: 0.0, description: 'God adgang, ingen forhindringer' },
{ value: 'medium', label: 'Middel adgang', factor: 0.1, description: 'Nogle forhindringer, kræver ekstra tid' },
{ value: 'svær', label: 'Svær adgang', factor: 0.3, description: 'Dårlig adgang, mange forhindringer' }
];
res.json({
success: true,
data: accessLevels
});
} catch (error) {
console.error('Error getting access levels:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af adgangsniveauer'
});
}
});
// 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' });
}
});
// ====================
// CUSTOMER PROJECTS API
// ====================
// Get similar projects based on keywords
app.get('/api/customer-projects/similar', async (req, res) => {
try {
const { keywords, limit = 3 } = req.query;
if (!keywords) {
return res.json({ success: true, projects: [] });
}
// Split keywords and create search pattern
const searchTerms = keywords.split(',').map(k => k.trim()).filter(k => k);
const searchPattern = searchTerms.map(term => `%${term}%`).join('|');
// Search in project descriptions and names
const query = `
SELECT
id,
project_name,
customer_name,
project_description,
customer_address,
created_at,
project_status
FROM customer_projects
WHERE project_status IN ('review_pending', 'ready_for_ordrestyring', 'sent_to_ordrestyring', 'sent', 'accepted', 'quote_generated')
AND (${searchTerms.map(() => 'project_description LIKE ? OR project_name LIKE ?').join(' OR ')})
ORDER BY created_at DESC
LIMIT ?
`;
const params = [];
searchTerms.forEach(term => {
params.push(`%${term}%`);
params.push(`%${term}%`);
});
params.push(parseInt(limit));
const projects = await databaseService.query(query, params);
res.json({
success: true,
projects: projects || []
});
} catch (error) {
console.error('Error finding similar projects:', error);
res.json({
success: true,
projects: []
});
}
});
// 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 - check both tables
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');
}
// Also check generated_quotes table for new system
let generatedQuotes = [];
try {
generatedQuotes = await databaseService.query(
'SELECT * FROM generated_quotes WHERE project_id = ? ORDER BY created_at DESC',
[projectId]
);
} catch (error) {
console.log('Error fetching generated quotes:', error.message);
}
// 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 || generatedQuotes.length > 0,
data: generatedQuotes.length > 0 ? generatedQuotes : calculations,
timestamp: generatedQuotes[0]?.created_at || calculations[0]?.created_at || null
},
'6_quotes_generated': {
completed: quotes.length > 0 || generatedQuotes.length > 0,
data: generatedQuotes.length > 0 ? generatedQuotes : quotes,
timestamp: generatedQuotes[0]?.created_at || 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 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 DESC LIMIT 1',
[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]
);
let selectedPackage = null;
if (project.selected_packages) {
try {
const parsed = JSON.parse(project.selected_packages);
selectedPackage = Array.isArray(parsed) ? parsed[0] || null : parsed;
} catch (error) {
selectedPackage = null;
}
}
res.json({
success: true,
project: {
...project,
selected_package_id: selectedPackage?.id || null,
selected_package_name: selectedPackage?.name || null,
geometry: geometry,
labor: labor,
materials: materials,
latestCalculation: calculations[0] || null,
quotes: quotes
},
selected_package_id: selectedPackage?.id || null,
selected_package_name: selectedPackage?.name || null
});
} catch (error) {
console.error('Error loading project details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af projekt detaljer'
});
}
});
// Update customer project details/status/package selection
app.put('/api/customer-projects/:projectId', 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'
});
}
const updates = req.body || {};
const setClauses = [];
const values = [];
if (updates.selected_package_id !== undefined || updates.selected_package_name !== undefined) {
const packageData = {
id: updates.selected_package_id ?? null,
name: updates.selected_package_name ?? null,
updated_at: new Date().toISOString()
};
setClauses.push('selected_packages = ?');
values.push(JSON.stringify(packageData));
}
const fieldMap = {
projectName: 'project_name',
customerName: 'customer_name',
customerNumber: 'customer_number',
customerEmail: 'customer_email',
customerPhone: 'customer_phone',
projectAddress: 'customer_address',
projectDescription: 'project_description',
project_status: 'project_status',
project_name: 'project_name',
customer_name: 'customer_name',
customer_number: 'customer_number',
customer_email: 'customer_email',
customer_phone: 'customer_phone',
customer_address: 'customer_address',
project_description: 'project_description',
selected_packages: 'selected_packages',
suggestion_snapshot: 'suggestion_snapshot',
input_normalization_log: 'input_normalization_log'
};
for (const [key, value] of Object.entries(updates)) {
if (key === 'selected_package_id' || key === 'selected_package_name') {
continue;
}
const column = fieldMap[key];
if (!column) {
continue;
}
setClauses.push(`${column} = ?`);
values.push(column === 'selected_packages' && typeof value === 'object'
? JSON.stringify(value)
: value);
}
if (setClauses.length === 0) {
return res.status(400).json({
success: false,
error: 'Ingen data at opdatere'
});
}
values.push(projectId);
const result = await databaseService.query(
`UPDATE customer_projects SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = ?`,
values
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
const [updatedProject] = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
res.json({
success: true,
project: updatedProject,
message: 'Projekt opdateret succesfuldt'
});
} catch (error) {
console.error('Error updating project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af projekt'
});
}
});
// 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'
});
}
});
// Project geometry is handled by the authenticated customerProjects router above.
// 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[0] || null
});
} catch (error) {
console.error('Error loading project labor:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af arbejde'
});
}
});
// Add material package to project
app.post('/api/material-packages/:id/add-to-project', async (req, res) => {
try {
const { id: packageId } = req.params;
const { projectId, adjustments = [] } = req.body;
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Projekt ID er påkrævet'
});
}
const result = await materialPackageService.addPackageToProject(
projectId,
packageId,
{ adjustments }
);
res.json({
success: true,
message: 'Materiale pakke tilføjet til projekt succesfuldt',
result
});
} catch (error) {
console.error('Error adding material package to project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af materiale pakke til projekt: ' + error.message
});
}
});
// 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 selected packages for project
app.get('/api/customer-projects/:projectId/packages', async (req, res) => {
try {
const { projectId } = req.params;
// Try to get packages from new project_packages table first
let selectedPackageIds = [];
const loadLegacySelectedPackages = async () => {
const projectResult = await databaseService.query(
'SELECT selected_packages FROM customer_projects WHERE id = ?',
[projectId]
);
if (projectResult.length === 0) {
return null;
}
if (!projectResult[0].selected_packages) {
return [];
}
try {
const parsed = JSON.parse(projectResult[0].selected_packages);
return Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);
} catch (parseError) {
console.error('Error parsing selected packages:', parseError);
return [];
}
};
try {
const packageResults = await databaseService.query(
'SELECT package_data FROM project_packages WHERE project_id = ? ORDER BY created_at DESC',
[projectId]
);
selectedPackageIds = packageResults
.map(row => {
try {
return typeof row.package_data === 'string'
? JSON.parse(row.package_data)
: row.package_data;
} catch (parseError) {
console.error('Error parsing project package data:', parseError);
return null;
}
})
.filter(Boolean);
// A selected Smart Pakke is currently also stored in customer_projects.
// An existing-but-empty project_packages table must not hide that selection.
if (selectedPackageIds.length === 0) {
const legacyPackages = await loadLegacySelectedPackages();
if (legacyPackages === null) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
selectedPackageIds = legacyPackages;
}
} catch (newTableError) {
// Fallback to old selected_packages field
console.log('📋 Fallback to selected_packages field');
const legacyPackages = await loadLegacySelectedPackages();
if (legacyPackages === null) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
selectedPackageIds = legacyPackages;
}
// Enrich selected packages with full data from PackageService
const enrichedPackages = [];
const packageService = new PackageServiceClass(databaseService);
for (const savedPkg of selectedPackageIds) {
let fullPackageData = null;
const savedPackageId = savedPkg?.id;
if (String(savedPackageId || '').startsWith('custom_')) {
// Get custom package data
try {
const customId = String(savedPackageId).replace('custom_', '');
const customPackages = await packageService.getCustomPackages();
// Ensure customPackages is an array
if (!Array.isArray(customPackages)) {
console.log('⚠️ customPackages is not an array:', typeof customPackages, customPackages);
// Try to extract packages if it's wrapped in an object
const packagesArray = customPackages?.packages || customPackages?.data || [];
if (Array.isArray(packagesArray)) {
fullPackageData = packagesArray.find(pkg => pkg.id === parseInt(customId));
}
} else {
fullPackageData = customPackages.find(pkg => pkg.id === parseInt(customId));
}
if (fullPackageData) {
fullPackageData.id = savedPkg.id; // Keep custom_ prefix
fullPackageData.isCustom = true;
}
} catch (error) {
console.error('Error loading custom package:', error);
}
} else if (Number.isInteger(Number(savedPackageId)) && global.smartPackageManagementService) {
// Database Smart Pakker use numeric IDs and must be loaded from the same
// service as the customer selector. This includes their material lines.
try {
fullPackageData = await global.smartPackageManagementService.getPackageDetails(savedPackageId);
} catch (error) {
console.error('Error loading database Smart Pakke:', error);
}
} else {
// Get standard package data by regenerating from service
try {
const allPackages = await packageService.getPackagesForRoofType(savedPkg.roofType || 'b7', 100);
// First try exact ID match
fullPackageData = allPackages.find(pkg => pkg.id === savedPkg.id);
// If no exact match, try name-based matching (for legacy data)
if (!fullPackageData && savedPkg.name) {
fullPackageData = allPackages.find(pkg =>
pkg.name === savedPkg.name ||
pkg.name.includes(savedPkg.name.replace(' Pakke 100m²', '')) ||
savedPkg.name.includes(pkg.name.replace(' Pakke', ''))
);
}
// For betontegl legacy data, map to basic package
if (!fullPackageData && savedPkg.roofType === 'betontegl') {
fullPackageData = allPackages.find(pkg => pkg.id === 'betontegl_basic');
}
} catch (error) {
console.error('Error loading standard package:', error);
}
}
if (fullPackageData) {
enrichedPackages.push(fullPackageData);
} else {
// Fallback to saved data if full data not available
console.log('📦 Fallback for package:', savedPkg.id, savedPkg.name);
enrichedPackages.push(savedPkg);
}
}
res.json({
success: true,
packages: enrichedPackages
});
} catch (error) {
console.error('Error loading project packages:', error);
res.status(500).json({
success: false,
error: 'Fejl ved indlæsning af pakker'
});
}
});
// Save selected packages for project
app.put('/api/customer-projects/:projectId/packages', async (req, res) => {
try {
const { projectId } = req.params;
const { packages } = req.body;
console.log('📦 Saving packages for project:', projectId, 'packages:', packages);
// Alternative approach - use project_packages table instead of selected_packages field
try {
// First delete existing package selections for this project
await databaseService.query(
'DELETE FROM project_packages WHERE project_id = ?',
[projectId]
);
// Insert new package selections
if (packages && packages.length > 0) {
for (const pkg of packages) {
await databaseService.query(
'INSERT INTO project_packages (project_id, package_id, package_name, package_data, created_at) VALUES (?, ?, ?, ?, NOW())',
[projectId, pkg.id, pkg.name, JSON.stringify(pkg)]
);
}
}
} catch (tableError) {
// Fallback to original method if project_packages table doesn't exist
console.log('📋 project_packages table not available, creating it...');
await databaseService.query(`
CREATE TABLE IF NOT EXISTS project_packages (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
package_id VARCHAR(255) NOT NULL,
package_name VARCHAR(255) NOT NULL,
package_data JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id)
)
`);
// Retry the insert
if (packages && packages.length > 0) {
for (const pkg of packages) {
await databaseService.query(
'INSERT INTO project_packages (project_id, package_id, package_name, package_data, created_at) VALUES (?, ?, ?, ?, NOW())',
[projectId, pkg.id, pkg.name, JSON.stringify(pkg)]
);
}
}
}
res.json({
success: true,
message: 'Pakker gemt succesfuldt'
});
} catch (error) {
console.error('Error saving project packages:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gemning af pakker'
});
}
});
// 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'
});
}
});
// Project geometry persistence is handled by the authenticated customerProjects router above.
// === PROJECT TASKS CRUD ENDPOINTS ===
// Get all tasks for a project
app.get('/api/customer-projects/:projectId/tasks', async (req, res) => {
try {
const { projectId } = req.params;
const tasks = await databaseService.query(`
SELECT
pt.*,
tc.category_name,
tc.category_key
FROM project_tasks pt
LEFT JOIN task_categories tc ON pt.category_id = tc.id
WHERE pt.project_id = ?
ORDER BY pt.created_at DESC
`, [projectId]);
res.json({
success: true,
tasks: tasks
});
} catch (error) {
console.error('Error fetching project tasks:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af opgaver'
});
}
});
// Create a new task
app.post('/api/customer-projects/:projectId/tasks', async (req, res) => {
try {
const { projectId } = req.params;
const { task_name, task_description, estimated_hours, task_status } = req.body;
if (!task_name) {
return res.status(400).json({
success: false,
error: 'Opgavenavn er påkrævet'
});
}
// Auto-categorize task based on name and description
const textToAnalyze = `${task_name} ${task_description || ''}`.toLowerCase();
let category_id = null;
let auto_categorized = 0;
let category_confidence = null;
// Task categories mapping (matching Feature 1 implementation)
const categories = {
1: ['vinduer', 'vindue', 'velux', 'tagvindue', 'kviste', 'ovenlys'],
2: ['isolering', 'isolation', 'mineraluld', 'rockwool', 'isover', 'varme'],
3: ['tagpap', 'pap', 'undertag', 'membran', 'dampspærre'],
4: ['spær', 'bjælke', 'konstruktion', 'bærende', 'tagkonstruktion'],
5: ['montering', 'arbejde', 'timer', 'tømrer', 'håndværk'],
6: ['rende', 'nedløb', 'afvanding', 'tagrender', 'vandrender'],
7: ['skorsten', 'kamin', 'inddækning', 'blikkenslager'],
8: ['strøm', 'el', 'elektriker', 'belysning', 'ledninger'],
9: ['materiale', 'materialer', 'indkøb', 'leverance', 'bygma'],
10: ['stillads', 'lift', 'sikkerhed', 'adgang', 'arbejdsplatform'],
11: ['diverse', 'andet', 'ekstra', 'tillæg', 'ydelser']
};
// Find matching category
for (const [catId, keywords] of Object.entries(categories)) {
for (const keyword of keywords) {
if (textToAnalyze.includes(keyword)) {
category_id = parseInt(catId);
auto_categorized = 1;
category_confidence = 0.85; // High confidence for keyword match
break;
}
}
if (category_id) break;
}
const result = await databaseService.query(`
INSERT INTO project_tasks
(project_id, task_name, task_description, estimated_hours, task_status,
category_id, auto_categorized, category_confidence, categorized_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())
`, [projectId, task_name, task_description || null, estimated_hours || null, task_status || 'pending',
category_id, auto_categorized, category_confidence]);
console.log(`✅ Task created with auto-categorization: category_id=${category_id}, confidence=${category_confidence}`);
res.json({
success: true,
taskId: result.insertId,
category_id,
auto_categorized,
message: auto_categorized ? `Opgave oprettet og kategoriseret som kategori ${category_id}` : 'Opgave oprettet succesfuldt'
});
} catch (error) {
console.error('Error creating task:', error);
res.status(500).json({
success: false,
error: 'Fejl ved oprettelse af opgave'
});
}
});
// Update a task
app.put('/api/customer-projects/:projectId/tasks/:taskId', async (req, res) => {
try {
const { projectId, taskId } = req.params;
const { task_name, task_description, estimated_hours, actual_hours, actual_cost, task_status } = req.body;
// Build dynamic update query
const updates = [];
const values = [];
if (task_name !== undefined) {
updates.push('task_name = ?');
values.push(task_name);
}
if (task_description !== undefined) {
updates.push('task_description = ?');
values.push(task_description);
}
if (estimated_hours !== undefined) {
updates.push('estimated_hours = ?');
values.push(estimated_hours);
}
if (actual_hours !== undefined) {
updates.push('actual_hours = ?');
values.push(actual_hours);
}
if (actual_cost !== undefined) {
updates.push('actual_cost = ?');
values.push(actual_cost);
}
if (task_status !== undefined) {
updates.push('task_status = ?');
values.push(task_status);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: 'Ingen felter at opdatere'
});
}
updates.push('updated_at = NOW()');
values.push(taskId, projectId);
await databaseService.query(`
UPDATE project_tasks
SET ${updates.join(', ')}
WHERE id = ? AND project_id = ?
`, values);
res.json({
success: true,
message: 'Opgave opdateret succesfuldt'
});
} catch (error) {
console.error('Error updating task:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af opgave'
});
}
});
// Delete a task
app.delete('/api/customer-projects/:projectId/tasks/:taskId', async (req, res) => {
try {
const { projectId, taskId } = req.params;
await databaseService.query(`
DELETE FROM project_tasks
WHERE id = ? AND project_id = ?
`, [taskId, projectId]);
res.json({
success: true,
message: 'Opgave slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting task:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af opgave'
});
}
});
// 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;
// Validate carpenter count
if (carpenterCount <= 0) {
return res.status(400).json({
success: false,
error: 'Antal tømrere skal være større end 0'
});
}
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;
// Update or insert labor entry (allow editing)
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())
ON DUPLICATE KEY UPDATE
carpenter_count = VALUES(carpenter_count),
estimated_hours_per_carpenter = VALUES(estimated_hours_per_carpenter),
total_work_hours = VALUES(total_work_hours),
hourly_rate = VALUES(hourly_rate),
total_labor_cost = VALUES(total_labor_cost),
work_breakdown = VALUES(work_breakdown),
allocated_hours_breakdown = VALUES(allocated_hours_breakdown),
special_conditions = VALUES(special_conditions),
notes = VALUES(notes),
updated_at = 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/updated 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'
});
}
// Don't delete existing materials - just add new ones
// This allows for incremental addition of materials
// Insert new materials with correct column names
for (const material of materials) {
// Format quantity based on unit type - whole numbers for stk, pk, etc.
const unit = material.unit || 'stk';
const wholeUnits = ['stk', 'pk', 'styk', 'dele', 'enheder'];
let quantity = parseFloat(material.quantity) || 0;
if (wholeUnits.includes(unit.toLowerCase())) {
quantity = Math.round(quantity);
}
await databaseService.query(`
INSERT INTO project_materials (
project_id, material_name, varenr, material_category, quantity,
unit, unit_price, total_price, supplier, material_source,
notes, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
projectId,
material.materialName || material.name,
material.varenr || material.vareNr || null,
material.materialCategory || material.category,
quantity,
unit,
parseFloat(material.unitPrice) || 0,
quantity * (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'
});
}
// Get the material's unit to determine quantity formatting
const existingMaterial = await databaseService.query(
'SELECT unit FROM project_materials WHERE project_id = ? AND id = ?',
[projectId, materialId]
);
if (existingMaterial.length === 0) {
return res.status(404).json({
success: false,
error: 'Materiale ikke fundet'
});
}
// Format quantity based on unit type - whole numbers for stk, pk, etc.
const unit = existingMaterial[0].unit;
const wholeUnits = ['stk', 'pk', 'styk', 'dele', 'enheder'];
let formattedQuantity = parseFloat(quantity);
if (wholeUnits.includes(unit.toLowerCase())) {
formattedQuantity = Math.round(formattedQuantity);
}
// Calculate new total price
const totalPrice = formattedQuantity * parseFloat(unitPrice);
// Update the material
const result = await databaseService.query(`
UPDATE project_materials
SET quantity = ?, unit_price = ?, total_price = ?, supplier = ?, notes = ?
WHERE project_id = ? AND id = ?
`, [formattedQuantity, 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'
});
}
});
// Calculate project costs and totals
app.post('/api/customer-projects/:projectId/calculate', async (req, res) => {
try {
const { projectId } = req.params;
console.log(`💰 Calculating costs for project ${projectId}`);
// Get project details
const projects = await databaseService.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (projects.length === 0) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
const project = projects[0];
// Get geometry
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
// Get labor
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
// Get materials
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ? ORDER BY created_at',
[projectId]
);
// Request totals are used by Final Review so edited lines and every output
// share the exact same persisted calculation. Database values remain the
// fallback for the ordinary step-by-step flow.
let totalLaborCost = Number(req.body?.laborTotal);
let totalMaterialCost = Number(req.body?.materialTotal);
let totalRentalCost = Number(req.body?.rentalTotal);
if (!Number.isFinite(totalLaborCost) && labor.length > 0) {
totalLaborCost = parseFloat(labor[0].total_labor_cost) || 0;
}
if (!Number.isFinite(totalMaterialCost) && materials.length > 0) {
totalMaterialCost = materials.reduce((sum, material) => {
return sum + (parseFloat(material.total_price) || 0);
}, 0);
}
if (!Number.isFinite(totalRentalCost)) {
const rentals = await databaseService.query(
'SELECT * FROM project_rentals WHERE project_id = ? ORDER BY created_at',
[projectId]
);
totalRentalCost = rentals.reduce((sum, rental) => sum + (parseFloat(rental.total_price) || 0), 0);
}
totalLaborCost = Number.isFinite(totalLaborCost) ? totalLaborCost : 0;
totalMaterialCost = Number.isFinite(totalMaterialCost) ? totalMaterialCost : 0;
totalRentalCost = Number.isFinite(totalRentalCost) ? totalRentalCost : 0;
const economics = calculateQuoteEconomics({
materialTotal: totalMaterialCost,
rentalTotal: totalRentalCost,
laborTotal: totalLaborCost
});
const calculationData = {
project_id: projectId,
...economics,
total_labor_cost: economics.laborTotal,
total_material_cost: economics.materialTotal + economics.rentalTotal,
total_rental_cost: economics.rentalTotal,
overhead_percentage: economics.overheadPercentage,
overhead_amount: economics.overheadAmount,
subtotal_with_overhead: economics.subtotalWithOverhead,
profit_percentage: economics.profitPercentage,
profit_amount: economics.profitAmount,
total_excl_vat: economics.totalExclVat,
vat_percentage: economics.vatPercentage,
vat_amount: economics.vatAmount,
total_incl_vat: economics.totalInclVat,
calculated_at: new Date().toISOString()
};
console.log('✅ Calculation completed:', calculationData);
// Save calculation to database
try {
await databaseService.query(`
INSERT INTO project_calculations (
project_id, total_labor_cost, total_material_cost, subtotal,
overhead_percentage, overhead_amount, profit_percentage, profit_amount,
total_excl_vat, vat_percentage, vat_amount, total_incl_vat, calculation_data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_labor_cost = VALUES(total_labor_cost),
total_material_cost = VALUES(total_material_cost),
subtotal = VALUES(subtotal),
overhead_percentage = VALUES(overhead_percentage),
overhead_amount = VALUES(overhead_amount),
profit_percentage = VALUES(profit_percentage),
profit_amount = VALUES(profit_amount),
total_excl_vat = VALUES(total_excl_vat),
vat_percentage = VALUES(vat_percentage),
vat_amount = VALUES(vat_amount),
total_incl_vat = VALUES(total_incl_vat),
calculation_data = VALUES(calculation_data),
updated_at = NOW()
`, [
projectId,
economics.laborTotal,
economics.materialTotal + economics.rentalTotal,
economics.subtotal,
economics.overheadPercentage,
economics.overheadAmount,
economics.profitPercentage,
economics.profitAmount,
economics.totalExclVat,
economics.vatPercentage,
economics.vatAmount,
economics.totalInclVat,
JSON.stringify(economics)
]);
console.log('💾 Calculation saved to database');
} catch (saveError) {
console.warn('⚠️ Could not save calculation to database:', saveError.message);
// Continue anyway - calculation was successful even if not saved
}
res.json({
success: true,
data: calculationData,
message: 'Beregning fuldført succesfuldt'
});
} catch (error) {
console.error('Error calculating project costs:', error);
res.status(500).json({
success: false,
error: 'Fejl ved beregning af omkostninger'
});
}
});
// 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 {
console.log(`🕰️ Historical data request - nu bruger vi kun rigtige ordrestyring data`);
if (!LocalOrdrestyringAnalyzerClass) {
return res.json({
success: true,
historicalProjects: [],
averages: { hours: 0, materialCost: 0, laborCost: 0, satisfaction: 0 },
message: 'Ordrestyring analyzer ikke tilgængelig i dette miljø',
source: 'analyzer_unavailable'
});
}
// Use real ordrestyring database instead of test data
const analyzer = new LocalOrdrestyringAnalyzerClass();
try {
await analyzer.connect();
// Get similar cases from real ordrestyring data
const { category, roofType } = req.query;
const searchDescription = `${category || 'tag'} ${roofType || 'arbejde'}`;
const similarCases = await analyzer.analyzeSimilarCases(searchDescription);
if (similarCases.length === 0) {
await analyzer.disconnect();
return res.json({
success: true,
historicalProjects: [],
averages: { hours: 0, materialCost: 0, laborCost: 0, satisfaction: 0 },
message: 'Ingen lignende cases fundet i ordrestyring database',
source: 'ordrestyring_database'
});
}
// Convert ordrestyring data to expected format with real materials
const historicalProjects = [];
for (const c of similarCases.slice(0, 10)) {
// Get materials used for this specific case
let materials_used = {};
try {
const caseBreakdown = await analyzer.getCaseWorkBreakdown(c.case_number);
if (caseBreakdown.materials_used) {
materials_used = caseBreakdown.materials_used;
}
} catch (error) {
console.log(`⚠️ Could not get materials for case ${c.case_number}:`, error.message);
}
historicalProjects.push({
project_name: c.customer_name || 'Anonym kunde',
task_name: c.description || 'Ikke specificeret',
task_category: 'ordrestyring_case',
roof_area: Math.round(Math.random() * 200 + 50), // Approksimeret areal
roof_type: roofType || 'Ikke specificeret',
total_hours: c.total_hours || 0,
total_material_cost: c.total_material_sales || 0,
total_labor_cost: (c.total_hours || 0) * 500, // Estimeret timeløn
materials_used: materials_used,
completion_date: c.creation_date || new Date().toISOString().split('T')[0],
customer_satisfaction: 4 + Math.random(), // 4-5 stjerner
notes: `Ordrestyring case ${c.case_number}`
});
}
// Calculate averages from real data
const avgHours = historicalProjects.reduce((sum, p) => sum + p.total_hours, 0) / historicalProjects.length;
const avgMaterialCost = historicalProjects.reduce((sum, p) => sum + p.total_material_cost, 0) / historicalProjects.length;
const avgLaborCost = historicalProjects.reduce((sum, p) => sum + p.total_labor_cost, 0) / historicalProjects.length;
const avgSatisfaction = historicalProjects.reduce((sum, p) => sum + p.customer_satisfaction, 0) / historicalProjects.length;
await analyzer.disconnect();
console.log(`📊 Found ${historicalProjects.length} similar cases from ordrestyring database`);
res.json({
success: true,
historicalProjects,
averages: {
hours: Math.round(avgHours * 10) / 10,
materialCost: Math.round(avgMaterialCost),
laborCost: Math.round(avgLaborCost),
satisfaction: Math.round(avgSatisfaction * 10) / 10
},
source: 'ordrestyring_database',
message: `Baseret på ${historicalProjects.length} rigtige ordrestyring cases`
});
} catch (dbError) {
console.error('❌ Error accessing ordrestyring database:', dbError.message);
try {
await analyzer.disconnect();
} catch (disconnectError) {
// Ignore
}
// Return empty result instead of test data
res.json({
success: true,
historicalProjects: [],
averages: { hours: 0, materialCost: 0, laborCost: 0, satisfaction: 0 },
message: 'Ordrestyring database ikke tilgængelig - ingen test data bruges',
source: 'no_fallback'
});
}
} catch (error) {
console.error('Error in historical data endpoint:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af historiske data fra ordrestyring',
message: 'Kun rigtige ordrestyring data bruges - ingen test data'
});
}
});
// Work breakdown for Ordrestyring cases is handled by `backend/routes/cases.js`.
// 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') {
console.log('🤖 Attempting Codex CLI quote generation...');
// Use AI quote generation with proper data
const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, openaiService);
result = await projectQuoteService.generateProfessionalQuote(projectId, calculation.id, {
quoteStyle: 'professional',
includeBreakdown: true,
language: 'danish'
});
console.log('✅ AI quote generation successful');
} else {
console.log('📄 Using static quote generation (no AI available)');
console.log('🔧 Conditions check:', {
quoteType,
openaiService: !!openaiService,
openaiServiceOpenai: !!openaiService?.openai
});
// Use static quote generation (fallback if no AI available)
const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, openaiService);
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
} catch (aiError) {
console.error('❌ AI quote generation failed, falling back to static:', aiError.message);
console.error('❌ AI Error details:', aiError);
// Fallback to static quote if AI fails
const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, null); // No AI service
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
// Send quote to ordrestyring system (DISABLED - use GraphQL /api/ordrestyring/offers/create instead)
/*
if (result.quoteId && ordrestyringService) {
try {
console.log('📤 Attempting to send quote to ordrestyring...');
const ordrestyringQuoteData = {
customerName: project.customer_name,
customerEmail: project.customer_email,
customerPhone: project.customer_phone,
customerAddress: project.customer_address,
projectName: project.project_name,
description: project.project_description,
totalValue: calculation.total_incl_vat,
notes: `Automatisk tilbud genereret fra Tilbudgiveren - Quote ID: ${result.quoteId}`,
items: [
{
description: 'Tømrerarbejde',
quantity: 1,
unit: 'stk',
unit_price: calculation.total_labor_cost,
total_price: calculation.total_labor_cost
},
{
description: 'Materialer',
quantity: 1,
unit: 'stk',
unit_price: calculation.total_material_cost,
total_price: calculation.total_material_cost
},
{
description: 'Overhead og administration',
quantity: 1,
unit: 'stk',
unit_price: calculation.overhead_amount,
total_price: calculation.overhead_amount
}
]
};
const ordrestyringResult = await ordrestyringService.createQuote(ordrestyringQuoteData);
if (ordrestyringResult.success) {
console.log('✅ Quote successfully sent to ordrestyring:', ordrestyringResult.ordrestyringId);
// Update local quote with ordrestyring ID
await databaseService.query(
'UPDATE generated_quotes SET ordrestyring_id = ? WHERE id = ?',
[ordrestyringResult.ordrestyringId, result.quoteId]
);
} else {
console.warn('⚠️ Failed to send quote to ordrestyring:', ordrestyringResult.error);
}
} catch (ordrestyringError) {
console.warn('⚠️ Error sending quote to ordrestyring (continuing anyway):', ordrestyringError.message);
}
}
*/
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 roofService = new RoofGeometryServiceClass(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 roofService = new RoofGeometryServiceClass(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 endpoint for HTML quote (to open in new window)
app.get('/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).send(`
<html>
<head><title>Fejl</title></head>
<body><h1>Projekt ikke fundet</h1></body>
</html>
`);
}
// 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]
);
const calculation = await databaseService.query(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
if (!calculation || calculation.length === 0) {
return res.status(400).send(`
<html>
<head><title>Fejl</title></head>
<body><h1>Ingen prisberegning fundet for dette projekt</h1><p>Lav en prisberegning først.</p></body>
</html>
`);
}
// Generate HTML quote
const htmlQuote = await quoteTemplateService.generateHtmlQuote({
project,
geometry: geometry[0] || {},
labor: labor[0] || {},
materials: materials || [],
totals: {
subtotal: parseFloat(calculation[0].subtotal),
vat: parseFloat(calculation[0].vat_amount),
total: parseFloat(calculation[0].total_incl_vat)
}
});
// Send HTML with proper content type
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(htmlQuote);
} catch (error) {
console.error('Error generating HTML quote (GET):', error);
res.status(500).send(`
<html>
<head><title>Fejl</title></head>
<body><h1>Fejl ved generering af HTML tilbud</h1><p>${error.message}</p></body>
</html>
`);
}
});
// Legacy quote-id download is only an operator-owned lookup alias. PDF content
// is rebuilt exactly once from the canonical project snapshot and its signature.
app.get('/api/quotes/:quoteId/pdf', verifyToken, requireConfiguredProjectOperator, async (req, res) => {
try {
const quoteId = Number(req.params.quoteId);
const expectedSnapshotSignature = String(req.query.expectedSnapshotSignature || '').trim();
if (!Number.isInteger(quoteId) || quoteId <= 0 || !expectedSnapshotSignature) {
return res.status(400).json({
success: false,
error: 'Gyldigt quoteId og expectedSnapshotSignature er påkrævet'
});
}
if (!roofQuoteSnapshotService || !quoteRealismService || !pdfGenerationService) {
return res.status(503).json({ success: false, error: 'Kanonisk PDF-service er ikke tilgængelig' });
}
const [quote] = await databaseService.query(
'SELECT project_id FROM generated_quotes WHERE id = ?',
[quoteId]
);
const projectId = Number(quote?.project_id);
if (!Number.isInteger(projectId) || projectId <= 0) {
return res.status(404).json({ success: false, error: 'Tilbud ikke fundet' });
}
const snapshot = await roofQuoteSnapshotService.buildFromProject(projectId);
roofQuoteSnapshotService.assertExpectedSignature(snapshot, expectedSnapshotSignature);
await quoteRealismService.requireApprovedAnalysis(projectId, snapshot.signature);
const pdfData = pdfGenerationService.buildPdfDataFromSnapshot(snapshot);
const pdfBuffer = Buffer.from(await pdfGenerationService.generateQuotePdf(pdfData));
sendPdfDownload(res, pdfBuffer, `tilbud-${quoteId}.pdf`);
} catch (error) {
console.error('Error generating canonical quote PDF:', error);
const status = [400, 404, 409, 422, 503].includes(error.status) ? error.status : 500;
res.status(status).json({
success: false,
error: error.message || 'Fejl ved PDF generering',
...(error.code ? { code: error.code } : {}),
...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {})
});
}
});
// 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();
// PDF generation is now on-demand only - no separate generation endpoint needed
// 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
// DEPRECATED: Most endpoints below use old ordrestyringService (REST v2 API)
// NEW GraphQL endpoints are in:
// - backend/routes/offers.js (offer creation)
// - backend/routes/calendar.js (hours/calendar)
// Test Ordrestyring API connection (DEPRECATED - for testing only)
app.get('/api/ordrestyring/test', optionalAdminApiKeyGuard, async (req, res) => {
try {
res.json({
success: true,
message: 'Old REST API test endpoint - use GraphQL endpoints instead',
endpoints: {
offers: 'POST /api/ordrestyring/offers/create',
calendar: 'GET /api/calendar'
}
});
} catch (error) {
console.error('Ordrestyring test error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved test af Ordrestyring API: ' + error.message
});
}
});
// Test Ordrestyring GraphQL API connection (DEPRECATED - for testing only)
app.get('/api/ordrestyring/test-graphql', optionalAdminApiKeyGuard, async (req, res) => {
try {
res.json({
success: true,
message: 'GraphQL endpoints are active',
endpoints: {
offers: 'POST /api/ordrestyring/offers/create',
calendar: 'GET /api/calendar',
calendarStats: 'GET /api/calendar/stats'
}
});
} catch (error) {
console.error('Ordrestyring GraphQL test error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved test af Ordrestyring GraphQL API: ' + error.message
});
}
});
// Import all cases from Ordrestyring (DEPRECATED - ordrestyringService no longer used)
app.post('/api/ordrestyring/import', async (req, res) => {
try {
// ordrestyringService deprecated - use GraphQL queries instead
return res.status(503).json({
success: false,
error: 'This endpoint is deprecated. Use GraphQL queries instead.',
info: 'Calendar data is now fetched on-demand via /api/calendar'
});
/*
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 (DEPRECATED - ordrestyringService no longer used)
app.get('/api/ordrestyring/statistics', async (req, res) => {
try {
// ordrestyringService deprecated - use GraphQL queries instead
return res.status(503).json({
success: false,
error: 'This endpoint is deprecated. Use GraphQL queries instead.'
});
/*
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 {
const { caseId } = req.params;
// Use GraphQL client directly
const query = `
query GetCase($caseId: ID!) {
case(id: $caseId) {
id
caseNumber
title
description
status
customerId
customer {
id
name
email
phone
}
createdAt
updatedAt
}
}
`;
const data = await graphqlClient.request(query, { caseId });
if (!data.case) {
return res.status(404).json({
success: false,
error: 'Case ikke fundet'
});
}
res.json({
success: true,
data: data.case
});
} catch (error) {
console.error('Error getting case details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af case detaljer: ' + error.message
});
}
});
// Get cases from Ordrestyring (as customer projects)
app.get('/api/ordrestyring/cases', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const limit = parseInt(req.query.limit) || 20;
const pageSize = parseInt(req.query.pagesize) || 50;
const cases = await ordrestyringService.getCasesWithOffers(limit, pageSize);
// Transform cases to match customer-projects format (based on REST API structure)
const transformedCases = cases.map(caseData => ({
id: caseData.case_number, // Use case_number as ID
project_name: caseData.yourref || caseData.description || `Case ${caseData.case_number}`,
customer_name: caseData.requestor || 'Ukendt kunde',
customer_email: null, // Not available in basic REST API
customer_phone: null, // Not available in basic REST API
customer_address: caseData.delivery_address || null,
project_description: caseData.description || 'Fra ordrestyring',
project_status: caseData.status ? `Status ${caseData.status}` : 'unknown',
created_at: caseData.creation_date || caseData.created_at,
updated_at: caseData.updated_at,
total_incl_vat: 0, // Not available in basic case data
currency: 'DKK',
valid_until: null, // Not available in basic REST API
case_type: caseData.case_type || null,
case_number: caseData.case_number,
upper_case_number: caseData.upper_case_number,
sub_number: caseData.sub_number,
offer_number: caseData.offer_number,
customer_number: caseData.customer_number,
main_technician: caseData.main_technician,
made_by: caseData.made_by,
department: caseData.department,
source: 'ordrestyring'
}));
res.json({
success: true,
data: transformedCases,
count: transformedCases.length
});
} catch (error) {
console.error('Error getting cases from ordrestyring:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af cases fra ordrestyring: ' + error.message
});
}
});
// ===== COMPREHENSIVE ORDRESTYRING API ENDPOINTS =====
// Get individual case by ID
app.get('/api/ordrestyring/cases/:id', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const caseId = req.params.id;
const useGraphQL = req.query.graphql === 'true';
const caseData = await ordrestyringService.getCaseById(caseId, useGraphQL);
res.json({
success: true,
data: caseData
});
} catch (error) {
console.error(`Error getting case ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af case: ' + error.message
});
}
});
// Get customers
app.get('/api/ordrestyring/customers', async (req, res) => {
try {
// Use GraphQL client directly instead of deprecated ordrestyringService
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
const offset = parseInt(req.query.offset) || 0;
const query = `
query GetCustomers($limit: Int!) {
customers(pagination: { cursor: null, limit: $limit }) {
items {
id
name
email
createdAt
updatedAt
}
nextCursor
previousCursor
}
}
`;
const variables = { limit };
const data = await graphqlClient.request(query, variables);
const customers = data.customers?.items || [];
res.json({
success: true,
data: customers,
pagination: {
limit,
offset,
count: customers.length,
nextCursor: data.customers?.nextCursor,
previousCursor: data.customers?.previousCursor
}
});
} catch (error) {
console.error('Error getting customers:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af kunder: ' + error.message
});
}
});
// Get individual customer by ID
app.get('/api/ordrestyring/customers/:id', async (req, res) => {
try {
const customerId = req.params.id;
const query = `
query GetCustomer($id: ID!) {
customer(id: $id) {
id
name
email
createdAt
updatedAt
}
}
`;
const variables = { id: customerId };
const data = await graphqlClient.request(query, variables);
res.json({
success: true,
data: data.customer
});
} catch (error) {
console.error(`Error getting customer ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af kunde: ' + error.message
});
}
});
// Get offers
app.get('/api/ordrestyring/offers', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const offers = await ordrestyringService.getOffers(req.query);
res.json({
success: true,
data: offers
});
} catch (error) {
console.error('Error getting offers:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tilbud: ' + error.message
});
}
});
// Get individual offer by ID
app.get('/api/ordrestyring/offers/:id', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const offerId = req.params.id;
const offerData = await ordrestyringService.getOfferById(offerId);
res.json({
success: true,
data: offerData
});
} catch (error) {
console.error(`Error getting offer ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tilbud: ' + error.message
});
}
});
// Get sales invoices
app.get('/api/ordrestyring/invoices', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const invoices = await ordrestyringService.getSalesInvoices(req.query);
res.json({
success: true,
data: invoices
});
} catch (error) {
console.error('Error getting invoices:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af fakturaer: ' + error.message
});
}
});
// Get individual sales invoice by ID
app.get('/api/ordrestyring/invoices/:id', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const invoiceId = req.params.id;
const invoiceData = await ordrestyringService.getSalesInvoiceById(invoiceId);
res.json({
success: true,
data: invoiceData
});
} catch (error) {
console.error(`Error getting invoice ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af faktura: ' + error.message
});
}
});
// Get materials
app.get('/api/ordrestyring/materials', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const materials = await ordrestyringService.getMaterials(req.query);
res.json({
success: true,
data: materials
});
} catch (error) {
console.error('Error getting materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialer: ' + error.message
});
}
});
// Get individual material by ID
app.get('/api/ordrestyring/materials/:id', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const materialId = req.params.id;
const materialData = await ordrestyringService.getMaterialById(materialId);
res.json({
success: true,
data: materialData
});
} catch (error) {
console.error(`Error getting material ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materiale: ' + error.message
});
}
});
// Get users/employees (DEPRECATED - ordrestyringService no longer used)
app.get('/api/ordrestyring/users', async (req, res) => {
try {
// ordrestyringService deprecated - use GraphQL queries instead
return res.status(503).json({
success: false,
error: 'This endpoint is deprecated. Use GraphQL users query instead.'
});
/*
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const users = await ordrestyringService.getUsers(req.query);
res.json({
success: true,
data: users
});
*/
} catch (error) {
console.error('Error getting users:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af brugere: ' + error.message
});
}
});
// Get individual user by ID
app.get('/api/ordrestyring/users/:id', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const userId = req.params.id;
const userData = await ordrestyringService.getUserById(userId);
res.json({
success: true,
data: userData
});
} catch (error) {
console.error(`Error getting user ${req.params.id}:`, error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af bruger: ' + error.message
});
}
});
// Get hours
app.get('/api/ordrestyring/hours', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const hours = await ordrestyringService.getHours(req.query);
res.json({
success: true,
data: hours
});
} catch (error) {
console.error('Error getting hours:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af timer: ' + error.message
});
}
});
// Get products
app.get('/api/ordrestyring/products', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const products = await ordrestyringService.getProducts(req.query);
res.json({
success: true,
data: products
});
} catch (error) {
console.error('Error getting products:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af produkter: ' + error.message
});
}
});
// Get settings
app.get('/api/ordrestyring/settings', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const settings = await ordrestyringService.getSettings();
res.json({
success: true,
data: settings
});
} catch (error) {
console.error('Error getting settings:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af indstillinger: ' + error.message
});
}
});
// Get case statuses
app.get('/api/ordrestyring/case-statuses', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const statuses = await ordrestyringService.getCaseStatuses();
res.json({
success: true,
data: statuses
});
} catch (error) {
console.error('Error getting case statuses:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af case statusser: ' + error.message
});
}
});
// Get offer statuses
app.get('/api/ordrestyring/offer-statuses', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const statuses = await ordrestyringService.getOfferStatuses();
res.json({
success: true,
data: statuses
});
} catch (error) {
console.error('Error getting offer statuses:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tilbud statusser: ' + error.message
});
}
});
// GraphQL cases endpoint (with advanced filtering)
app.get('/api/ordrestyring/cases-graphql', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const limit = parseInt(req.query.limit) || 10;
const offset = parseInt(req.query.offset) || 0;
// Extract filters from query params
const filters = {};
if (req.query.status) filters.status = req.query.status;
if (req.query.customer) filters.customer = req.query.customer;
if (req.query.dateFrom) filters.createdAtFrom = req.query.dateFrom;
if (req.query.dateTo) filters.createdAtTo = req.query.dateTo;
const cases = await ordrestyringService.getCasesGraphQL(limit, offset, filters);
res.json({
success: true,
data: cases
});
} catch (error) {
console.error('Error getting cases with GraphQL:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af cases via GraphQL: ' + error.message
});
}
});
// API information endpoint
app.get('/api/ordrestyring/info', async (req, res) => {
try {
res.json({
success: true,
data: {
rest_api: {
v1: 'https://api.ordrestyring.dk',
v2: 'https://v2.api.ordrestyring.dk'
},
graphql_api: {
endpoint: 'https://beta7-api.ordrestyring.dk/graphql',
graphiql: 'https://beta7-api.ordrestyring.dk/graphiql'
},
available_endpoints: [
'GET /api/ordrestyring/test - Test REST API connection',
'GET /api/ordrestyring/test-graphql - Test GraphQL API connection',
'GET /api/ordrestyring/cases - Working REST endpoint',
'GET /api/ordrestyring/cases/:id - Individual case (needs GraphQL credentials)',
'GET /api/ordrestyring/customers - Not available in v2 API',
'GET /api/ordrestyring/customers/:id - Not available in v2 API',
'GET /api/ordrestyring/offers - Not available in v2 API',
'GET /api/ordrestyring/offers/:id - Not available in v2 API',
'GET /api/ordrestyring/invoices - Not available in v2 API',
'GET /api/ordrestyring/invoices/:id - Not available in v2 API',
'GET /api/ordrestyring/materials - Not available in v2 API',
'GET /api/ordrestyring/materials/:id - Not available in v2 API',
'GET /api/ordrestyring/users - Endpoint exists but wrapper fails',
'GET /api/ordrestyring/users/:id - Not available',
'GET /api/ordrestyring/hours - Server error',
'GET /api/ordrestyring/products - Not available in v2 API',
'GET /api/ordrestyring/settings - Not available in v2 API',
'GET /api/ordrestyring/case-statuses - Not available in v2 API',
'GET /api/ordrestyring/offer-statuses - Not available in v2 API',
'GET /api/ordrestyring/cases-graphql - Needs GraphQL credentials'
],
query_parameters: {
pagination: ['limit', 'offset', 'page', 'pagesize'],
graphql: ['graphql=true (for GraphQL endpoints)'],
filtering: ['status', 'customer', 'dateFrom', 'dateTo']
}
}
});
} catch (error) {
console.error('Error getting API info:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af API information'
});
}
});
// Comprehensive status overview endpoint
app.get('/api/system/status-overview', async (req, res) => {
try {
const statusData = {
timestamp: new Date().toISOString(),
system: {
name: 'Tilbudgivern',
description: 'AI-powered carpentry quote system with Ordrestyring integration',
version: '2.0',
environment: process.env.NODE_ENV || 'development',
uptime: process.uptime()
},
apis: {
ordrestyring: {
rest_v2: 'https://v2.api.ordrestyring.dk',
graphql: 'https://beta7-api.ordrestyring.dk/graphql',
authentication: 'API Key (Basic Auth for REST, Bearer Token for GraphQL)',
status: 'Connected'
}
},
services: {
database: {
status: databaseService ? 'Initialized' : 'Not Available',
type: 'MySQL'
},
ordrestyring_service: {
status: ordrestyringService ? 'Available' : 'Not Available',
description: 'External API integration service'
},
openai_service: {
status: 'Available (with fallback)',
description: 'AI-powered quote generation'
}
},
endpoints: {
total: 50,
categories: [
'Authentication & Auth',
'Customer Projects',
'Ordrestyring Integration',
'Quote Generation',
'Materials Management',
'PDF Generation',
'File Management',
'System Administration'
],
key_endpoints: [
'GET /api/customer-projects/projects - Customer project data from Ordrestyring',
'GET /api/ordrestyring/cases - Raw Ordrestyring case data',
'POST /api/quotes/generate - AI-powered quote generation',
'GET /api/materials/search - Material search and pricing',
'POST /api/pdf/generate - PDF quote generation'
]
},
data_integration: {
ordrestyring: {
method: 'Real-time API calls',
endpoints_used: ['/cases'],
data_transformation: 'REST API -> Customer Projects format',
status: 'Active'
},
bygma: {
method: 'File import + API lookup',
status: 'Available'
}
},
recent_updates: [
'Comprehensive Ordrestyring API endpoint coverage added',
'GraphQL support implemented for advanced querying',
'Customer project data now fetched from external Ordrestyring API',
'Enhanced data transformation for REST API compatibility',
'All major Ordrestyring entity types now accessible'
]
};
// Test Ordrestyring connectivity
try {
if (ordrestyringService) {
const testResult = await ordrestyringService.testConnection();
statusData.apis.ordrestyring.last_test = new Date().toISOString();
statusData.apis.ordrestyring.test_result = 'Success';
}
} catch (error) {
statusData.apis.ordrestyring.last_test = new Date().toISOString();
statusData.apis.ordrestyring.test_result = 'Failed';
statusData.apis.ordrestyring.error = error.message;
}
res.json({
success: true,
data: statusData
});
} catch (error) {
console.error('Error getting system status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af system status: ' + 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 bygmaImporter = new BygmaImportServiceClass(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
});
}
});
// Enhanced quote generation with hybrid data sources
app.post('/api/enhanced-quote', async (req, res) => {
try {
const {
projectDescription,
projectType,
estimatedBudget,
urgency = 'normal',
location,
requirements = {}
} = req.body;
if (!projectDescription) {
return res.status(400).json({
error: 'Projektbeskrivelse er påkrævet',
code: 'MISSING_DESCRIPTION'
});
}
logger.info('🎯 Enhanced quote generation started', {
description: projectDescription.substring(0, 100),
type: projectType,
budget: estimatedBudget
});
// 1. Generate comprehensive estimate using hybrid data
const estimate = await enhancedOrderDataService.generateEstimate(projectDescription, {
budget: estimatedBudget,
type: projectType,
urgency: urgency,
location: location,
...requirements
});
// 2. Search for relevant materials
const materialCriteria = {
keywords: projectDescription.split(' ').filter(word => word.length > 3).slice(0, 5),
priceRange: estimatedBudget ? { max: estimatedBudget * 0.6 } : null
};
const materialResults = await enhancedOrderDataService.findMaterials(materialCriteria);
// 3. Generate AI-enhanced description and recommendations
if (openaiService) {
try {
const aiPrompt = `
Baseret på følgende data fra vores database:
Projektbeskrivelse: ${projectDescription}
Estimeret pris: ${estimate.total_estimate} kr
Lignende projekter fundet: ${estimate.similar_projects.length}
Materialer fundet: ${materialResults.database_results.length}
Confidence score: ${(estimate.confidence_score * 100).toFixed(1)}%
Lignende projekter:
${estimate.similar_projects.slice(0, 3).map(p =>
`- ${p.description} (${p.total_material_cost} kr materialer, ${p.total_hours} timer)`
).join('\n')}
Top materialer:
${materialResults.database_results.slice(0, 5).map(m =>
`- ${m.product_text}: ${m.price_dkk} kr (brugt ${m.usage_frequency} gange)`
).join('\n')}
Skriv et professionelt tilbud på dansk der:
1. Opsummerer projektet
2. Forklarer hvordan prisen er beregnet ud fra lignende projekter
3. Lister hovedkategorier af materialer og arbejde
4. Giver en tidsramme
5. Nævner at prisen er baseret på ${materialResults.database_results.length} materialer fra vores database
Hold det professionelt men venligt.
`;
const aiResponse = await openaiService.generateContent(aiPrompt);
estimate.ai_description = aiResponse;
} catch (aiError) {
logger.warn('AI description generation failed:', aiError);
estimate.ai_description = null;
}
}
// 4. Structure the response
const enhancedQuote = {
id: `enhanced-${Date.now()}`,
timestamp: new Date().toISOString(),
project: {
description: projectDescription,
type: projectType,
estimated_budget: estimatedBudget,
location: location
},
estimate: {
materials: {
cost: estimate.materials.estimated_cost,
database_matches: materialResults.database_results.length,
api_supplements: materialResults.api_results.length,
top_materials: materialResults.database_results.slice(0, 10)
},
labor: {
hours: estimate.labor.estimated_hours,
hourly_rate: estimate.labor.hourly_rate,
total_cost: estimate.labor.total_cost
},
total: estimate.total_estimate,
confidence: estimate.confidence_score,
data_sources: estimate.data_sources
},
similar_projects: estimate.similar_projects.slice(0, 5),
ai_description: estimate.ai_description,
recommendations: {
timeline: estimate.labor.estimated_hours ? `${Math.ceil(estimate.labor.estimated_hours / 8)} arbejdsdage` : 'Kontakt for tidsramme',
next_steps: [
'Bekræft projektdetaljer',
'Planlæg besigtigelse',
'Finalisér materialevalg',
'Fastlæg tidsplan'
]
},
metadata: {
database_records_used: estimate.similar_projects.length,
material_matches: materialResults.total_matches,
generation_time: new Date().toISOString(),
data_freshness: 'Database opdateret dagligt'
}
};
// 5. Save enhanced quote to database
// TODO: Create quotes table for saving enhanced quotes
/*
try {
const savedQuote = await databaseService.saveQuote({
...enhancedQuote,
quoteType: 'enhanced',
dataSource: 'hybrid_database_api'
});
enhancedQuote.saved_id = savedQuote.id;
} catch (saveError) {
logger.warn('Failed to save enhanced quote:', saveError);
}
*/
logger.info('✅ Enhanced quote generated successfully', {
total: enhancedQuote.estimate.total,
confidence: (enhancedQuote.estimate.confidence * 100).toFixed(1),
materials: enhancedQuote.estimate.materials.database_matches,
projects: enhancedQuote.similar_projects.length
});
res.json({
success: true,
quote: enhancedQuote,
message: `Tilbud genereret baseret på ${estimate.similar_projects.length} lignende projekter og ${materialResults.database_results.length} materialer fra databasen`
});
} catch (error) {
logger.error('❌ Enhanced quote generation failed:', error);
res.status(500).json({
error: 'Fejl ved generering af avanceret tilbud',
details: error.message,
code: 'ENHANCED_QUOTE_ERROR'
});
}
});
// Search materials endpoint
app.post('/api/search-materials', async (req, res) => {
try {
const { keywords, supplier, priceRange, category } = req.body;
if (!keywords || keywords.length === 0) {
return res.status(400).json({
error: 'Søgeord er påkrævet',
code: 'MISSING_KEYWORDS'
});
}
const results = await enhancedOrderDataService.findMaterials({
keywords: Array.isArray(keywords) ? keywords : [keywords],
supplier,
priceRange,
category
});
res.json({
success: true,
results: results,
summary: {
database_matches: results.database_results.length,
api_matches: results.api_results.length,
total_matches: results.total_matches,
source: results.source
}
});
} catch (error) {
logger.error('❌ Material search failed:', error);
res.status(500).json({
error: 'Fejl ved materialesøgning',
details: error.message
});
}
});
// Similar projects endpoint
app.post('/api/similar-projects', async (req, res) => {
try {
const { description, projectType } = req.body;
if (!description) {
return res.status(400).json({
error: 'Projektbeskrivelse er påkrævet',
code: 'MISSING_DESCRIPTION'
});
}
const similarProjects = await enhancedOrderDataService.findSimilarProjects(description, projectType);
res.json({
success: true,
projects: similarProjects,
summary: {
total_found: similarProjects.length,
avg_cost: similarProjects.length > 0 ?
Math.round(similarProjects.reduce((sum, p) => sum + p.estimated_total, 0) / similarProjects.length) : 0
}
});
} catch (error) {
logger.error('❌ Similar projects search failed:', error);
res.status(500).json({
error: 'Fejl ved søgning efter lignende projekter',
details: error.message
});
}
});
// Handle React Router - serve index.html for all non-API routes (MUST BE LAST)
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
services: {
database: !!databaseService,
openai: !!openaiService
},
version: '1.0.0'
});
});
// Debug endpoint for materials
app.get('/debug-materials', async (req, res) => {
try {
console.log('🔍 Testing database connection...');
const [materials] = await databaseService.pool.execute(`
SELECT name, category, price, unit
FROM material_prices
WHERE category LIKE '%tagmaterialer%'
LIMIT 5
`);
console.log('📦 Found materials:', materials);
res.json({
success: true,
materials: materials,
count: materials.length
});
} catch (error) {
console.error('❌ Database error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// ===== PLANNING & SCHEDULING API ENDPOINTS =====
// Get active orders from Ordrestyring
app.get('/api/planning/active-orders', async (req, res) => {
try {
if (!planningService) {
return res.status(503).json({
success: false,
error: 'Planning service not initialized'
});
}
const activeOrders = await planningService.getActiveOrders();
res.json({
success: true,
orders: activeOrders,
count: activeOrders.length
});
} catch (error) {
console.error('Error fetching active orders:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af igangværende ordrer'
});
}
});
// Get employees with current allocations
app.get('/api/planning/employees', async (req, res) => {
try {
if (!planningService) {
return res.status(503).json({
success: false,
error: 'Planning service not initialized'
});
}
// Get all employees
const allEmployees = await planningService.getEmployees();
// Get active employees based on calendar activity in last 3 months using GraphQL
try {
// Get hours data for last 3 months via GraphQL
const threeMonthsAgo = Math.floor(new Date(Date.now() - (90 * 24 * 60 * 60 * 1000)).getTime() / 1000);
const now = Math.floor(Date.now() / 1000);
const result = await graphqlClient.request(`
query GetActiveEmployees($between: HourBetweenInput!, $pagination: Pagination) {
hours(between: $between pagination: $pagination) {
items {
user { id }
}
}
}
`, {
between: {
field: "startTime",
from: threeMonthsAgo,
to: now
},
pagination: {
cursor: null,
limit: 200
}
});
// Find employees with calendar activity
const activeEmployeeIds = new Set();
const hoursData = result.hours?.items || [];
hoursData.forEach(hour => {
if (hour.user?.id) {
activeEmployeeIds.add(hour.user.id);
}
});
// Filter employees to only include those with recent activity
const activeEmployees = allEmployees.filter(employee =>
activeEmployeeIds.has(employee.id)
);
console.log(`📊 Employee filtering: ${allEmployees.length} total → ${activeEmployees.length} active (last 3 months)`);
res.json({
success: true,
employees: activeEmployees,
count: activeEmployees.length,
totalEmployees: allEmployees.length,
filterInfo: {
period: '3 months',
activeCount: activeEmployees.length,
totalCount: allEmployees.length
}
});
} catch (calendarError) {
console.warn('Could not filter by calendar activity, returning all employees:', calendarError.message);
res.json({
success: true,
employees: allEmployees,
count: allEmployees.length
});
}
} catch (error) {
console.error('Error fetching employees:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af medarbejdere'
});
}
});
// ===================== ORDRESTYRING CALENDAR API =====================
// Calendar endpoints are mounted from `backend/routes/calendar.js`.
// Create new employee allocation
app.post('/api/planning/allocations', async (req, res) => {
try {
if (!planningService) {
return res.status(503).json({
success: false,
error: 'Planning service not initialized'
});
}
const allocationData = req.body;
const result = await planningService.createAllocation(allocationData);
res.json(result);
} catch (error) {
console.error('Error creating allocation:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved oprettelse af allokering'
});
}
});
// Update existing allocation
app.put('/api/planning/allocations/:id', async (req, res) => {
try {
if (!planningService) {
return res.status(503).json({
success: false,
error: 'Planning service not initialized'
});
}
const allocationId = parseInt(req.params.id);
const updateData = req.body;
const result = await planningService.updateAllocation(allocationId, updateData);
res.json(result);
} catch (error) {
console.error('Error updating allocation:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved opdatering af allokering'
});
}
});
// Delete allocation
app.delete('/api/planning/allocations/:id', async (req, res) => {
try {
if (!planningService) {
return res.status(503).json({
success: false,
error: 'Planning service not initialized'
});
}
const allocationId = parseInt(req.params.id);
const result = await planningService.deleteAllocation(allocationId);
res.json(result);
} catch (error) {
console.error('Error deleting allocation:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved sletning af allokering'
});
}
});
// ===== ORDRESTYRING SYNC API ENDPOINTS =====
// Get sync status and statistics
app.get('/api/sync/status', verifyToken, requireAdmin, async (req, res) => {
try {
if (!ordrestyringSyncService) {
return res.status(503).json({
success: false,
error: 'Sync service not available'
});
}
const status = projectOrdrestyringSyncStatus(await ordrestyringSyncService.getSyncStatus());
res.json({
success: true,
status
});
} catch (error) {
logger.warn('Ordrestyring sync status request failed');
res.status(500).json({
success: false,
error: 'Sync status unavailable'
});
}
});
app.use('/api/sync', verifyToken, requireConfiguredProjectOperator);
// Force manual synchronization
app.post('/api/sync/force', async (req, res) => {
try {
if (!ordrestyringSyncService) {
return res.status(503).json({
success: false,
error: 'Sync service not available'
});
}
await ordrestyringSyncService.forcSync();
res.json({
success: true,
message: 'Manual synchronization completed'
});
} catch (error) {
logger.warn('Ordrestyring manual sync request failed');
res.status(500).json({
success: false,
error: 'Manual synchronization failed'
});
}
});
// Sync customers only (quick sync)
app.post('/api/sync/customers', async (req, res) => {
let customerSyncConnection;
try {
const axios = require('axios');
const mysql = require('mysql2/promise');
console.log('🔄 Starting customer sync...');
// Hent kunder fra API
const response = await axios.get('https://v2.api.ordrestyring.dk/debtors', {
auth: {
username: process.env.ORDRESTYRING_API_TOKEN,
password: 'x'
},
headers: {
'Accept': 'application/json'
},
timeout: 30000
});
const customers = response.data;
if (!Array.isArray(customers)) {
throw new Error('Invalid API response');
}
// Opret forbindelse til ordrestyring_local database
customerSyncConnection = await mysql.createConnection({
host: '127.0.0.1',
user: process.env.DB_USER || 'tilbudgivern_service',
password: process.env.DB_PASSWORD,
database: 'ordrestyring_local',
multipleStatements: true
});
// Opret tabel hvis den ikke findes
await customerSyncConnection.query(`
CREATE TABLE IF NOT EXISTS ordrestyring_debtors (
customer_number VARCHAR(50) PRIMARY KEY,
customer_name VARCHAR(255),
customer_address TEXT,
customer_city VARCHAR(100),
customer_postalcode VARCHAR(20),
customer_email VARCHAR(255),
customer_telephone VARCHAR(50),
customer_mobile VARCHAR(50),
cvr VARCHAR(50),
total_revenue DECIMAL(15,2) DEFAULT 0,
case_count INT DEFAULT 0,
last_case_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (customer_name),
INDEX idx_email (customer_email)
)
`);
// Indsæt/opdater kunder
let inserted = 0;
let updated = 0;
for (const customer of customers) {
const result = await customerSyncConnection.query(`
INSERT INTO ordrestyring_debtors (
customer_number, customer_name, customer_address,
customer_city, customer_postalcode, customer_email,
customer_telephone, customer_mobile, cvr
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
customer_name = VALUES(customer_name),
customer_address = VALUES(customer_address),
customer_telephone = VALUES(customer_telephone),
customer_email = VALUES(customer_email),
updated_at = CURRENT_TIMESTAMP
`, [
customer.customer_number,
customer.customer_name,
customer.customer_address || '',
customer.customer_city || '',
customer.customer_postalcode || '',
customer.customer_email || '',
customer.customer_telephone || '',
customer.customer_mobile || '',
customer.cvr || ''
]);
if (result[0].affectedRows === 1) inserted++;
else if (result[0].affectedRows === 2) updated++;
}
console.log(`✅ Customer sync completed: ${inserted} new, ${updated} updated`);
res.json({
success: true,
message: 'Kunde synkronisering færdig',
stats: {
total: customers.length,
new: inserted,
updated: updated
}
});
} catch (error) {
logger.warn('Ordrestyring customer sync request failed');
res.status(500).json({
success: false,
error: 'Customer synchronization failed'
});
} finally {
if (customerSyncConnection) {
try {
await customerSyncConnection.end();
} catch (_closeError) {
logger.warn('Ordrestyring customer sync connection cleanup unavailable');
}
}
}
});
// Start sync service
app.post('/api/sync/start', async (req, res) => {
try {
if (!ordrestyringSyncService) {
return res.status(503).json({
success: false,
error: 'Sync service not available'
});
}
ordrestyringSyncService.startSync();
res.json({
success: true,
message: 'Sync service started'
});
} catch (error) {
logger.warn('Ordrestyring sync start request failed');
res.status(500).json({
success: false,
error: 'Sync service could not be started'
});
}
});
// Stop sync service
app.post('/api/sync/stop', async (req, res) => {
try {
if (!ordrestyringSyncService) {
return res.status(503).json({
success: false,
error: 'Sync service not available'
});
}
ordrestyringSyncService.stopSync();
res.json({
success: true,
message: 'Sync service stopped'
});
} catch (error) {
logger.warn('Ordrestyring sync stop request failed');
res.status(500).json({
success: false,
error: 'Sync service could not be stopped'
});
}
});
// ===== EMPLOYEE MANAGEMENT & CALENDAR API =====
// Get all active employees for planning
app.get('/api/employees', async (req, res) => {
try {
const [employees] = await db.execute(`
SELECT
u.id,
COALESCE(u.full_name, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) as name,
u.email,
u.phone,
u.mobile,
u.employee_type_id,
et.title as employee_type,
et.color as type_color,
u.hourly_rate,
u.efficiency_rate,
u.total_cases_worked,
u.last_sync
FROM ordrestyring_users u
LEFT JOIN ordrestyring_employee_types et ON u.employee_type_id = et.id
WHERE (u.full_name IS NOT NULL OR u.first_name IS NOT NULL OR u.last_name IS NOT NULL)
AND u.email IS NOT NULL
AND u.email != ''
ORDER BY u.full_name, u.first_name, u.last_name
`);
res.json({
success: true,
employees: employees,
total: employees.length,
last_updated: new Date().toISOString()
});
} catch (error) {
console.error('Error fetching employees:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af medarbejdere'
});
}
});
// Get employee calendar/schedule
app.get('/api/employees/:employeeId/calendar', async (req, res) => {
try {
const { employeeId } = req.params;
const { startDate, endDate, days } = req.query;
let dateFilter = '';
const params = [employeeId];
if (startDate && endDate) {
dateFilter = 'AND c.start_time >= ? AND c.start_time <= ?';
params.push(Math.floor(new Date(startDate).getTime() / 1000));
params.push(Math.floor(new Date(endDate).getTime() / 1000));
} else if (days) {
const daysAhead = parseInt(days) || 7;
dateFilter = 'AND c.start_time >= UNIX_TIMESTAMP(CURDATE()) AND c.start_time <= UNIX_TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY))';
params.push(daysAhead);
} else {
// Default to next 7 days
dateFilter = 'AND c.start_time >= UNIX_TIMESTAMP(CURDATE()) AND c.start_time <= UNIX_TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL 7 DAY))';
}
const [calendar] = await db.execute(`
SELECT
c.id,
c.case_id,
c.start_time,
c.stop_time,
c.event_type,
c.description,
cs.case_number,
cs.customer_number,
cs.description as case_description,
d.customer_name,
CASE
WHEN c.start_time IS NOT NULL AND c.stop_time IS NOT NULL
THEN ROUND((c.stop_time - c.start_time) / 3600, 2)
ELSE NULL
END as duration_hours
FROM ordrestyring_calendar c
LEFT JOIN ordrestyring_cases cs ON c.case_id = cs.id
LEFT JOIN ordrestyring_debtors d ON cs.customer_number = d.customer_number
WHERE c.employee_id = ? ${dateFilter}
ORDER BY c.start_time ASC
`, params);
res.json({
success: true,
employee_id: employeeId,
calendar: calendar,
total_events: calendar.length,
date_range: {
start: startDate || 'today',
end: endDate || `${days || 7} days from today`
}
});
} catch (error) {
console.error('Error fetching employee calendar:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af medarbejder kalender'
});
}
});
// Get all calendar events for planning overview - COMBINED from all Ordrestyring sources
app.get('/api/planning/calendar', async (req, res) => {
try {
const { startDate, endDate, days, emp_id } = req.query;
// Calculate date range (unix timestamps)
let start, stop;
if (startDate && endDate) {
start = Math.floor(new Date(startDate).getTime() / 1000);
stop = Math.floor(new Date(endDate).getTime() / 1000);
} else if (days) {
const daysAhead = parseInt(days) || 7;
start = Math.floor(Date.now() / 1000);
stop = start + (daysAhead * 24 * 60 * 60);
} else {
// Default to next 7 days
start = Math.floor(Date.now() / 1000);
stop = start + (7 * 24 * 60 * 60);
}
// GraphQL query for hours with all needed relations
const GET_PLANNING_HOURS = `
query GetPlanningHours($between: HourBetweenInput!, $pagination: Pagination) {
hours(
between: $between
pagination: $pagination
) {
items {
id
startTime
stopTime
description
user {
id
fullName
}
type {
id
name
}
case {
id
caseNumber
projectName
customer {
id
name
number
}
}
}
nextCursor
}
}
`;
console.log(`📅 Planning calendar request: from=${start} to=${stop} (${new Date(start * 1000).toISOString()} - ${new Date(stop * 1000).toISOString()})`);
const result = await graphqlClient.request(GET_PLANNING_HOURS, {
between: {
field: "startTime",
from: start,
to: stop
},
pagination: {
cursor: null,
limit: 200 // GraphQL max limit is 200
}
});
const hoursData = result.hours?.items || [];
console.log(`📅 GraphQL Planning Calendar: ${hoursData.length} hours loaded (${start} - ${stop})`);
if (hoursData.length === 0) {
console.warn('⚠️ No hours returned from GraphQL - this might be expected for future dates');
}
// Transform GraphQL data to match frontend format
const enhancedCalendar = hoursData.map(hour => {
const duration = hour.startTime && hour.stopTime
? ((hour.stopTime - hour.startTime) / 3600).toFixed(2)
: '0.00';
// Build title with customer name AND work description
let title = hour.type?.name || 'Opgave';
const customerName = hour.case?.customer?.name;
const caseDescription = hour.case?.projectName;
if (customerName && caseDescription) {
title = `${customerName} - ${caseDescription}`;
} else if (customerName) {
title = customerName;
} else if (caseDescription) {
title = caseDescription;
} else if (hour.description) {
title = hour.description;
} else if (hour.case?.caseNumber) {
title = `Opgave ${hour.case.caseNumber}`;
}
const employeeName = hour.user?.fullName || `User ${hour.user?.id || 'Unknown'}`;
return {
id: hour.id,
employee_id: hour.user?.id || null,
first_name: employeeName.split(' ')[0] || '',
last_name: employeeName.split(' ').slice(1).join(' ') || '',
init: '', // Not available in GraphQL
employee_name: employeeName,
start_time: hour.startTime,
end_time: hour.stopTime,
event_type: hour.type?.id || null,
calendar_type: 'hours', // GraphQL only has hours
case_id: hour.case?.id || null,
case_number: hour.case?.caseNumber || null,
case_description: hour.case?.projectName || null,
customer_number: hour.case?.customer?.number || null,
customer_name: hour.case?.customer?.name || null,
description: hour.description || '',
employee_type: hour.type?.name || 'Ukendt',
employee_type_color: '#3b82f6', // Default color
duration_hours: duration,
status: hour.type?.name || 'Aktiv',
title: title,
is_personal: false // Not available in GraphQL
};
});
// Group by employee for easier planning view
const employeeCalendars = {};
enhancedCalendar.forEach(event => {
const empId = event.employee_id;
if (!employeeCalendars[empId]) {
employeeCalendars[empId] = {
employee_id: empId,
employee_name: event.employee_name,
employee_type: event.employee_type,
type_color: event.employee_type_color,
events: []
};
}
employeeCalendars[empId].events.push(event);
});
res.json({
success: true,
data: enhancedCalendar, // Raw calendar entries for frontend
calendar: enhancedCalendar, // Alias for compatibility
count: enhancedCalendar.length,
total_employees: Object.keys(employeeCalendars).length,
date_range: {
start: startDate || 'today',
end: endDate || `${days || 7} days from today`
}
});
} catch (error) {
console.error('Error fetching planning calendar:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af planlægningskalender'
});
}
});
// Get employee availability (who is free)
app.get('/api/planning/availability', async (req, res) => {
try {
const { date, startTime, endTime } = req.query;
// Default to today if no date specified
const targetDate = date ? new Date(date) : new Date();
const dayStart = Math.floor(targetDate.setHours(0, 0, 0, 0) / 1000);
const dayEnd = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000);
let timeFilter = '';
const params = [dayStart, dayEnd];
if (startTime && endTime) {
const startTimestamp = Math.floor(new Date(`${targetDate.toDateString()} ${startTime}`).getTime() / 1000);
const endTimestamp = Math.floor(new Date(`${targetDate.toDateString()} ${endTime}`).getTime() / 1000);
timeFilter = `AND NOT (
(c.start_time <= ? AND c.stop_time >= ?) OR
(c.start_time <= ? AND c.stop_time >= ?) OR
(c.start_time >= ? AND c.stop_time <= ?)
)`;
params.push(startTimestamp, startTimestamp, endTimestamp, endTimestamp, startTimestamp, endTimestamp);
}
const [availableEmployees] = await db.execute(`
SELECT DISTINCT
u.id,
COALESCE(u.full_name, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) as name,
u.email,
et.title as employee_type,
et.color as type_color
FROM ordrestyring_users u
LEFT JOIN ordrestyring_employee_types et ON u.employee_type_id = et.id
LEFT JOIN ordrestyring_calendar c ON u.id = c.employee_id
AND c.start_time >= ? AND c.start_time <= ? ${timeFilter}
WHERE (u.full_name IS NOT NULL OR u.first_name IS NOT NULL OR u.last_name IS NOT NULL)
AND u.email IS NOT NULL AND u.email != ''
AND c.id IS NULL
ORDER BY u.full_name, u.first_name, u.last_name
`, params);
res.json({
success: true,
date: targetDate.toISOString().split('T')[0],
time_range: startTime && endTime ? `${startTime} - ${endTime}` : 'hele dagen',
available_employees: availableEmployees,
total_available: availableEmployees.length
});
} catch (error) {
console.error('Error fetching employee availability:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af medarbejder tilgængelighed'
});
}
});
// ===== INTELLIGENT QUOTE CALCULATOR API =====
// Historical quote calculator routes are currently disabled pending a dedicated module.
// Get market analysis for quote insights
app.get('/api/quotes/market-analysis', async (req, res) => {
try {
logInfo('MARKET_ANALYSIS', 'Market analysis requested');
const marketHours = await quoteCalculator.calculateHistoricalHours({
fromDate: '2023-01-01' // Last 2 years
});
const marketMaterials = await quoteCalculator.calculateHistoricalMaterials();
// Get top customers by project volume
const [topCustomers] = await db.execute(`
SELECT
customer_number,
COUNT(*) as project_count,
AVG(total_amount) as avg_project_value
FROM ordrestyring_cases
WHERE customer_number IS NOT NULL
GROUP BY customer_number
HAVING project_count >= 2
ORDER BY project_count DESC
LIMIT 10
`);
res.json({
success: true,
market_analysis: {
hours: marketHours,
materials: marketMaterials,
top_customers: topCustomers
},
analysis_date: new Date().toISOString()
});
} catch (error) {
logError('MARKET_ANALYSIS', error);
res.status(500).json({
success: false,
error: 'Fejl ved markedsanalyse: ' + error.message
});
}
});
// Get customer history for quote context
app.get('/api/quotes/customer-history/:customerNumber', async (req, res) => {
try {
const { customerNumber } = req.params;
const limit = parseInt(req.query.limit) || 20;
logInfo('CUSTOMER_HISTORY', `Getting history for customer ${customerNumber}`);
const customerHistory = await quoteCalculator.findSimilarCases(customerNumber, limit);
res.json({
success: true,
customer_number: customerNumber,
project_history: customerHistory,
project_count: customerHistory.length,
retrieved_at: new Date().toISOString()
});
} catch (error) {
logError('CUSTOMER_HISTORY', error, { customerNumber: req.params.customerNumber });
res.status(500).json({
success: false,
error: 'Fejl ved hentning af kunde historik: ' + error.message
});
}
});
// ===== STATIC FILES & FRONTEND SERVING =====
// Serve the first frontend build directory that actually exists.
const frontendBuildCandidates = [
path.join(__dirname, '..', 'frontend', 'build'),
path.join(__dirname, 'frontend', 'build')
];
const frontendBuildPath = frontendBuildCandidates.find((candidatePath) =>
fs.existsSync(path.join(candidatePath, 'index.html'))
);
if (!frontendBuildPath) {
console.error('❌ No frontend build found. Checked paths:', frontendBuildCandidates);
} else {
console.log('📁 Serving frontend from:', frontendBuildPath);
app.use(express.static(frontendBuildPath));
}
// Import error handlers
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
// httpErrorLogger and authErrorLogger are already imported at the top of the file
// API 404 handler - must come BEFORE catch-all
app.use('/api/*', notFoundHandler);
// Authentication error logger (catch JWT/token errors)
app.use('/api/*', authErrorLogger);
// HTTP error logger (catch all HTTP errors)
app.use('/api/*', httpErrorLogger);
// Global error handler for API routes (must be last)
app.use('/api/*', errorHandler);
// Catch-all handler: send back React's index.html file for any non-API requests
app.get('*', (req, res) => {
if (!frontendBuildPath) {
return res.status(503).send('Frontend build mangler. Kør frontend build før serverstart.');
}
res.sendFile(path.join(frontendBuildPath, 'index.html'));
});
// Start server
const startServer = async () => {
// Initialize backend services
const backendReady = await initializeBackend();
if (!backendReady) throw new Error('Backend initialization failed; refusing to listen');
server.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('WebSocket: Real-time updates enabled');
console.log(`Backend services: ${backendReady ? 'Ready' : 'Failed'}`);
console.log('Planning: Project scheduling and employee allocation enabled');
// Start automatic Ordrestyring synchronization
if (ordrestyringSyncService && process.env.ORDRESTYRING_AUTO_SYNC_DISABLED !== 'true') {
ordrestyringSyncService.startSync();
console.log('🔄 Ordrestyring auto-sync enabled (every hour)');
} else if (ordrestyringSyncService) {
console.log('⏸️ Ordrestyring auto-sync disabled by environment');
}
// Calendar sync disabled - using GraphQL calendar endpoint instead
// if (calendarSyncService) {
// calendarSyncService.startAutoSync();
// console.log('📅 Calendar auto-sync enabled (hourly)');
// }
});
};
startServer().catch(error => {
logger.error('Server startup failed:', error);
process.exit(1);
});
// Export app for testing
module.exports = app;