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
* [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]>
11357 lines
353 KiB
JavaScript
11357 lines
353 KiB
JavaScript
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;
|