diff --git a/backend/sql/customer_project_system.sql b/backend/sql/customer_project_system.sql index c98f38e..8738bc1 100644 --- a/backend/sql/customer_project_system.sql +++ b/backend/sql/customer_project_system.sql @@ -6,16 +6,36 @@ CREATE TABLE IF NOT EXISTS customer_projects ( id INT AUTO_INCREMENT PRIMARY KEY, project_name VARCHAR(255) NOT NULL, customer_name VARCHAR(255) NOT NULL, + customer_number VARCHAR(50), customer_email VARCHAR(255), customer_phone VARCHAR(50), customer_address TEXT, project_description TEXT, - project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft', + selected_packages JSON, + suggestion_snapshot JSON, + input_normalization_log JSON, + project_status ENUM( + 'draft', + 'geometry_complete', + 'smart_package_complete', + 'review_pending', + 'ready_for_ordrestyring', + 'sent_to_ordrestyring', + 'accepted', + 'rejected', + 'geometry_pending', + 'labor_pending', + 'materials_pending', + 'calculation_ready', + 'quote_generated', + 'sent' + ) DEFAULT 'draft', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (project_status), INDEX idx_customer (customer_name), + INDEX idx_customer_number (customer_number), INDEX idx_created (created_at) ); diff --git a/backend/src/__tests__/materialPriceStatusService.test.js b/backend/src/__tests__/materialPriceStatusService.test.js new file mode 100644 index 0000000..cc65c19 --- /dev/null +++ b/backend/src/__tests__/materialPriceStatusService.test.js @@ -0,0 +1,81 @@ +const MaterialPriceStatusService = require('../services/materialPriceStatusService'); + +describe('MaterialPriceStatusService', () => { + test('selects the newest available source as primary source', async () => { + const db = { + query: jest.fn(async (sql) => { + if (sql.includes('FROM bygma_import_log')) { + return [{ + started_at: '2026-03-25T08:00:00.000Z', + completed_at: '2026-03-25T08:10:00.000Z', + status: 'completed', + imported_by: 'bygma-user', + filename: 'bygma.csv', + updated_prices: 120, + successful_rows: 900 + }]; + } + + if (sql.includes('FROM bygma_price_history')) { + return [{ latest_price_at: '2026-03-25T08:00:00.000Z' }]; + } + + if (sql.includes('FROM import_logs')) { + return [{ + started_at: '2026-03-20T08:00:00.000Z', + completed_at: '2026-03-20T08:10:00.000Z', + status: 'completed', + imported_by: 'stark-user', + filename: 'stark.csv', + updated_items: 45, + total_items: 300 + }]; + } + + if (sql.includes('FROM stark_materials_cache')) { + return [{ latest_price_at: '2026-03-20T08:00:00.000Z' }]; + } + + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + + const service = new MaterialPriceStatusService(db); + const result = await service.getStatus(); + + expect(result.primarySource).toMatchObject({ + source: 'Bygma', + importStatus: 'completed', + filename: 'bygma.csv' + }); + expect(result.sources).toHaveLength(2); + expect(result.sources[0].statusLabel || result.sources[1].statusLabel).toBeTruthy(); + }); + + test('returns missing status when import tables are unavailable', async () => { + const db = { + query: jest.fn(async () => { + throw new Error("Table 'tilbudgivern.bygma_import_log' doesn't exist"); + }) + }; + + const service = new MaterialPriceStatusService(db); + const result = await service.getStatus(); + + expect(result.primarySource).toBeNull(); + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'Bygma', + available: false, + freshness: 'missing' + }), + expect.objectContaining({ + source: 'Stark', + available: false, + freshness: 'missing' + }) + ]) + ); + }); +}); diff --git a/backend/src/__tests__/orderSuggestionService.test.js b/backend/src/__tests__/orderSuggestionService.test.js new file mode 100644 index 0000000..9c78512 --- /dev/null +++ b/backend/src/__tests__/orderSuggestionService.test.js @@ -0,0 +1,249 @@ +const OrderSuggestionService = require('../services/orderSuggestionService'); + +describe('OrderSuggestionService', () => { + test('builds a prefilled recommendation from local history and ordrestyring', async () => { + const execute = jest.fn(async (sql) => { + if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) { + return [[{ + id: 7, + project_name: 'Nyt tag B7', + project_description: 'Udskiftning af B7 eternittag på villa', + customer_name: 'Mikael Holck', + customer_number: 'C100', + roof_type: 'skraat_tag', + total_area: 132 + }]]; + } + + if (sql.includes('SELECT\n cp.id AS project_id')) { + return [[{ + project_id: 5, + project_name: 'Historisk B7 projekt', + project_description: 'Komplet udskiftning af B7 tag med undertag', + customer_name: 'Mikael Holck', + customer_number: 'C100', + project_status: 'accepted', + selected_packages: JSON.stringify({ id: 'b7_tag_udskiftning', name: 'B7 Tag Udskiftning - Komplet' }), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + roof_type: 'skraat_tag', + total_area: 120, + total_work_hours: 42, + hourly_rate: 580, + work_breakdown: JSON.stringify([ + { task: 'Tagplader montering', description: 'Montering af B7 plader', hours: 18 } + ]), + labor_notes: 'Historisk reference' + }]]; + } + + if (sql.includes('FROM project_materials')) { + return [[ + { + project_id: 5, + material_name: 'Eternit B7 tagplader', + material_category: 'Tagmateriale', + quantity: 1.1, + unit: 'm²', + unit_price: 245 + } + ]]; + } + + if (sql.includes('FROM generated_quotes')) { + return [[ + { + project_id: 5, + quote_text: 'Historisk kundevendt tilbud på B7-tag', + created_at: new Date().toISOString() + } + ]]; + } + + if (sql.includes('FROM ordrestyring_local.cases')) { + return [[ + { + case_number: '5001', + customer_number: 'C100', + customer_name: 'Mikael Holck', + description: 'Udskiftning af B7 tag på villa', + remarks: 'Komplet tagarbejde med stillads', + creation_date: new Date().toISOString(), + status: 'completed', + total_hours: 42, + hour_entries: 8 + }, + { + case_number: '5002', + customer_number: 'C200', + customer_name: 'Anden kunde', + description: 'Tagreparation omkring kip', + remarks: '', + creation_date: new Date().toISOString(), + status: 'completed', + total_hours: 12, + hour_entries: 2 + } + ]]; + } + + throw new Error(`Unexpected SQL: ${sql}`); + }); + + const service = new OrderSuggestionService({ + pool: { execute } + }); + + const result = await service.getOrderSuggestions(7, { limit: 5 }); + + expect(result.customerNumber).toBe('C100'); + expect(result.matches.length).toBeGreaterThanOrEqual(2); + expect(result.matches[0]).toMatchObject({ + sourceType: 'local_project', + projectName: 'Historisk B7 projekt', + sameCustomer: true + }); + expect(result.recommendation.confidence).toBe('high'); + expect(result.recommendation.suggestedDescription).toContain('Udskiftning af B7 eternittag'); + expect(result.recommendation.recommendedPackage).toMatchObject({ + packageId: 'b7_tag_udskiftning' + }); + expect(result.materialsDraft[0]).toMatchObject({ + name: 'Eternit B7 tagplader', + unit: 'm²' + }); + expect(result.laborDraft[0]).toMatchObject({ + name: 'Tagplader montering' + }); + }); + + test('returns a low-confidence fallback when no cases match', async () => { + const execute = jest.fn(async (sql) => { + if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) { + return [[{ + id: 11, + project_name: 'Ny terrasse', + project_description: '', + customer_name: 'Jens Hansen', + customer_number: null, + roof_type: null, + total_area: null + }]]; + } + + if (sql.includes('SELECT\n cp.id AS project_id')) { + return [[]]; + } + + if (sql.includes('FROM ordrestyring_local.cases')) { + return [[]]; + } + + throw new Error(`Unexpected SQL: ${sql}`); + }); + + const service = new OrderSuggestionService({ + pool: { execute } + }); + + const result = await service.getOrderSuggestions(11, { limit: 5 }); + + expect(result.matches).toEqual([]); + expect(result.recommendation.confidence).toBe('low'); + expect(result.recommendation.averageHistoricalHours).toBe(0); + expect(result.recommendation.summary).toContain('Ingen lokale historiske sager'); + expect(result.materialsDraft).toEqual([]); + expect(result.laborDraft).toEqual([]); + }); + + test('infers tegl smart package from ordrestyring history when no local package is stored', async () => { + const execute = jest.fn(async (sql) => { + if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) { + return [[{ + id: 21, + project_name: 'Omlægning af tegltag', + project_description: 'Renovering af tegltag på villa med ny rygning', + customer_name: 'Erik Andersen', + customer_number: 'C310', + roof_type: 'skraat_tag', + total_area: 184 + }]]; + } + + if (sql.includes('SELECT\n cp.id AS project_id')) { + return [[{ + project_id: 18, + project_name: 'Historisk tegltag', + project_description: 'Omlægning af tegltag med undertag', + customer_name: 'Poul Eriksen', + customer_number: 'C311', + project_status: 'accepted', + selected_packages: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + roof_type: 'skraat_tag', + total_area: 176, + total_work_hours: 108, + hourly_rate: 580, + work_breakdown: JSON.stringify([ + { task: 'Montering af tegltag', description: 'Nye tegl og afslutninger', hours: 46 } + ]), + labor_notes: 'Historisk tegl reference' + }]]; + } + + if (sql.includes('FROM project_materials')) { + return [[ + { + project_id: 18, + material_name: 'Tegl vingetagsten', + material_category: 'Tagmateriale', + quantity: 185, + unit: 'm²', + unit_price: 145 + } + ]]; + } + + if (sql.includes('FROM generated_quotes')) { + return [[ + { + project_id: 18, + quote_text: 'Historisk kundevendt tilbud på tegltag', + created_at: new Date().toISOString() + } + ]]; + } + + if (sql.includes('FROM ordrestyring_local.cases')) { + return [[ + { + case_number: '5305', + customer_number: 'C310', + customer_name: 'Erik Andersen', + description: 'Omlægning af tegltag med undertag', + remarks: 'Klassisk villa med ny rygning', + creation_date: new Date().toISOString(), + status: 'completed', + total_hours: 118, + hour_entries: 14 + } + ]]; + } + + throw new Error(`Unexpected SQL: ${sql}`); + }); + + const service = new OrderSuggestionService({ + pool: { execute } + }); + + const result = await service.getOrderSuggestions(21, { limit: 5 }); + + expect(result.recommendation.recommendedPackage).toMatchObject({ + packageId: 'tegl_tag_renovering', + name: 'Tegltag Renovering' + }); + expect(result.matches[0].description).toContain('tegltag'); + }); +}); diff --git a/backend/src/constants/projectStatuses.js b/backend/src/constants/projectStatuses.js new file mode 100644 index 0000000..740e6bb --- /dev/null +++ b/backend/src/constants/projectStatuses.js @@ -0,0 +1,74 @@ +const PROJECT_STATUS = { + DRAFT: 'draft', + GEOMETRY_COMPLETE: 'geometry_complete', + SMART_PACKAGE_COMPLETE: 'smart_package_complete', + REVIEW_PENDING: 'review_pending', + READY_FOR_ORDRESTYRING: 'ready_for_ordrestyring', + SENT_TO_ORDRESTYRING: 'sent_to_ordrestyring', + ACCEPTED: 'accepted', + REJECTED: 'rejected', + + // Legacy values kept for compatibility with older rows and routes. + GEOMETRY_PENDING: 'geometry_pending', + LABOR_PENDING: 'labor_pending', + MATERIALS_PENDING: 'materials_pending', + CALCULATION_READY: 'calculation_ready', + QUOTE_GENERATED: 'quote_generated', + SENT: 'sent' +}; + +const ACTIVE_WORKFLOW_PROJECT_STATUSES = [ + PROJECT_STATUS.DRAFT, + PROJECT_STATUS.GEOMETRY_COMPLETE, + PROJECT_STATUS.SMART_PACKAGE_COMPLETE, + PROJECT_STATUS.REVIEW_PENDING, + PROJECT_STATUS.READY_FOR_ORDRESTYRING, + PROJECT_STATUS.SENT_TO_ORDRESTYRING, + PROJECT_STATUS.ACCEPTED, + PROJECT_STATUS.REJECTED +]; + +const LEGACY_PROJECT_STATUSES = [ + PROJECT_STATUS.GEOMETRY_PENDING, + PROJECT_STATUS.LABOR_PENDING, + PROJECT_STATUS.MATERIALS_PENDING, + PROJECT_STATUS.CALCULATION_READY, + PROJECT_STATUS.QUOTE_GENERATED, + PROJECT_STATUS.SENT +]; + +const ALL_PROJECT_STATUSES = [ + ...ACTIVE_WORKFLOW_PROJECT_STATUSES, + ...LEGACY_PROJECT_STATUSES +]; + +const QUOTE_READY_PROJECT_STATUSES = [ + PROJECT_STATUS.REVIEW_PENDING, + PROJECT_STATUS.READY_FOR_ORDRESTYRING, + PROJECT_STATUS.SENT_TO_ORDRESTYRING, + PROJECT_STATUS.QUOTE_GENERATED, + PROJECT_STATUS.SENT, + PROJECT_STATUS.ACCEPTED +]; + +const OFFER_HISTORY_PROJECT_STATUSES = [ + PROJECT_STATUS.READY_FOR_ORDRESTYRING, + PROJECT_STATUS.SENT_TO_ORDRESTYRING, + PROJECT_STATUS.QUOTE_GENERATED, + PROJECT_STATUS.SENT, + PROJECT_STATUS.ACCEPTED +]; + +function buildProjectStatusEnumSql() { + return `ENUM(${ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', ')})`; +} + +module.exports = { + PROJECT_STATUS, + ACTIVE_WORKFLOW_PROJECT_STATUSES, + LEGACY_PROJECT_STATUSES, + ALL_PROJECT_STATUSES, + QUOTE_READY_PROJECT_STATUSES, + OFFER_HISTORY_PROJECT_STATUSES, + buildProjectStatusEnumSql +}; diff --git a/backend/src/routes/customerProjects.js b/backend/src/routes/customerProjects.js index 5b18e62..5dffb07 100644 --- a/backend/src/routes/customerProjects.js +++ b/backend/src/routes/customerProjects.js @@ -7,10 +7,16 @@ const ProjectMaterialService = require('../services/projectMaterialService'); const ProjectCalculationService = require('../services/projectCalculationService'); const ProjectQuoteGenerationService = require('../services/projectQuoteGenerationService'); const ProjectExperienceService = require('../services/projectExperienceService'); +const OrderSuggestionService = require('../services/orderSuggestionService'); +const MaterialPriceStatusService = require('../services/materialPriceStatusService'); const bygmaScraperService = require('../services/bygmaScraperService'); const databaseService = require('../services/databaseService'); const openaiService = require('../services/openaiService'); const logger = require('../utils/logger'); +const { + PROJECT_STATUS, + ALL_PROJECT_STATUSES +} = require('../constants/projectStatuses'); // Initialize services const customerProjectService = new CustomerProjectService(databaseService); @@ -20,6 +26,8 @@ const projectMaterialService = new ProjectMaterialService(databaseService); const projectCalculationService = new ProjectCalculationService(databaseService); const projectQuoteGenerationService = new ProjectQuoteGenerationService(databaseService, openaiService); const projectExperienceService = new ProjectExperienceService(databaseService); +const orderSuggestionService = new OrderSuggestionService(databaseService); +const materialPriceStatusService = new MaterialPriceStatusService(databaseService); // ==================== KUNDE PROJEKT ENDPOINTS ==================== @@ -118,18 +126,98 @@ router.get('/projects/:id', async (req, res) => { } }); +// Hent Ordrestyring-baserede forslag til nyt tilbud +router.get('/projects/:id/order-suggestions', async (req, res) => { + try { + const projectId = parseInt(req.params.id, 10); + + if (!projectId) { + return res.status(400).json({ + success: false, + error: 'Ugyldigt projekt ID' + }); + } + + const result = await orderSuggestionService.getOrderSuggestions(projectId, { + limit: req.query.limit + }); + + res.json({ + success: true, + ...result + }); + } catch (error) { + const status = error.status || 500; + logger.error('Error getting order suggestions:', error); + res.status(status).json({ + success: false, + error: status === 404 ? error.message : 'Fejl ved hentning af Ordrestyring forslag' + }); + } +}); + +router.post('/projects/:id/apply-suggestion', async (req, res) => { + try { + const projectId = parseInt(req.params.id, 10); + const { + suggestionSnapshot = {}, + recommendedPackage = null, + projectDescription = null, + inputNormalizationLog = null + } = req.body || {}; + + if (!projectId) { + return res.status(400).json({ + success: false, + error: 'Ugyldigt projekt ID' + }); + } + + const selectedPackages = recommendedPackage + ? { + id: recommendedPackage.packageId || recommendedPackage.id || null, + name: recommendedPackage.name || null, + source: recommendedPackage.source || 'historical_suggestion' + } + : null; + + const updated = await customerProjectService.updateProjectFields(projectId, { + project_description: projectDescription, + selected_packages: selectedPackages, + suggestion_snapshot: suggestionSnapshot, + input_normalization_log: inputNormalizationLog + }); + + if (!updated) { + return res.status(404).json({ + success: false, + error: 'Projekt ikke fundet' + }); + } + + const result = await customerProjectService.getProjectWithDetails(projectId); + + res.json({ + success: true, + project: result?.project || null, + message: 'Historisk forslag gemt på projektet' + }); + } catch (error) { + logger.error('Error applying order suggestion:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved gemning af forslag' + }); + } +}); + // Opdater projekt status router.patch('/projects/:id/status', async (req, res) => { try { const projectId = parseInt(req.params.id); const { status } = req.body; - - const validStatuses = [ - 'draft', 'geometry_pending', 'labor_pending', 'materials_pending', - 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected' - ]; - - if (!validStatuses.includes(status)) { + + if (!ALL_PROJECT_STATUSES.includes(status)) { return res.status(400).json({ success: false, error: 'Ugyldig status' @@ -151,6 +239,23 @@ router.patch('/projects/:id/status', async (req, res) => { } }); +router.get('/material-price-status', async (req, res) => { + try { + const status = await materialPriceStatusService.getStatus(); + + res.json({ + success: true, + ...status + }); + } catch (error) { + logger.error('Error fetching material price status:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af materialepris status' + }); + } +}); + // ==================== TAG GEOMETRI ENDPOINTS ==================== // Gem tag geometri @@ -196,7 +301,7 @@ router.post('/projects/:id/geometry', async (req, res) => { console.log('✅ [GEOMETRY] Saved successfully:', result); // Opdater projekt status - await customerProjectService.updateProjectStatus(projectId, 'labor_pending'); + await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.GEOMETRY_COMPLETE); res.json({ success: true, @@ -273,7 +378,7 @@ router.post('/projects/:id/labor', async (req, res) => { const result = await projectLaborService.saveProjectLabor(projectId, laborData); // Opdater projekt status - await customerProjectService.updateProjectStatus(projectId, 'materials_pending'); + await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE); res.json({ success: true, @@ -482,7 +587,7 @@ router.post('/projects/:id/materials/bulk', async (req, res) => { const result = await projectMaterialService.bulkAddMaterials(projectId, materials); // Opdater projekt status til calculation_ready hvis alle materialer er tilføjet - await customerProjectService.updateProjectStatus(projectId, 'calculation_ready'); + await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE); res.json({ success: true, @@ -682,7 +787,7 @@ router.post('/projects/:id/calculate', async (req, res) => { const result = await projectCalculationService.calculateProjectQuote(projectId, options); // Opdater projekt status - await customerProjectService.updateProjectStatus(projectId, 'quote_generated'); + await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.REVIEW_PENDING); res.json({ success: true, @@ -725,6 +830,48 @@ router.get('/projects/:id/calculation', async (req, res) => { } }); +router.post('/projects/:id/generate-quote-draft', async (req, res) => { + try { + const projectId = parseInt(req.params.id, 10); + const { + mode = 'ai', + quoteData = null, + suggestionData = null, + customInstructions = '' + } = req.body || {}; + + if (!projectId) { + return res.status(400).json({ + success: false, + error: 'Ugyldigt projekt ID' + }); + } + + const result = await projectQuoteGenerationService.generateStructuredQuoteDraft(projectId, { + mode, + quoteData, + suggestionData, + customInstructions + }); + + await customerProjectService.updateProjectFields(projectId, { + input_normalization_log: result.normalizationLog || null, + project_status: PROJECT_STATUS.REVIEW_PENDING + }); + + res.json({ + success: true, + ...result + }); + } catch (error) { + logger.error('Error generating quote draft:', error); + res.status(500).json({ + success: false, + error: error.message || 'Fejl ved generering af tilbudskladde' + }); + } +}); + // ==================== HJÆLPE ENDPOINTS ==================== // Hent tag typer og muligheder @@ -1654,4 +1801,3 @@ router.get('/:projectId/with-totals', async (req, res) => { }); module.exports = router; - diff --git a/backend/src/services/bootstrapServiceContainer.js b/backend/src/services/bootstrapServiceContainer.js new file mode 100644 index 0000000..35911c2 --- /dev/null +++ b/backend/src/services/bootstrapServiceContainer.js @@ -0,0 +1,92 @@ +/** + * Bootstrap and wire core backend services in one place. + * Keeps startup wiring explicit and avoids ad-hoc requires in handlers. + */ +async function bootstrapServiceContainer({ logger, db, healthPool }) { + const databaseService = require('./databaseService'); + const openaiService = require('./openaiService'); + const webPriceService = require('./webPriceService'); + + const DynamicImportService = require('./dynamicImportService'); + const OrderStatusService = require('./orderStatusService'); + const QuoteTemplateService = require('./quoteTemplateService'); + const PdfGenerationService = require('./pdfGenerationService'); + const RealDataProjectSuggestionService = require('./realDataProjectSuggestionService'); + const EnhancedOrderDataService = require('./enhancedOrderDataService'); + const MaterialPackageService = require('./materialPackageService'); + const SmartPackageManagementService = require('./smartPackageManagementService'); + const PlanningService = require('./planningService'); + const OrdrestyringSyncService = require('./ordrestyringSyncService'); + + // Shared constructors/services used in route handlers + const PriceImportService = require('./priceImportService').PriceImportService; + const BygmaPrisbogImportService = require('./bygmaPrisbogImportService'); + const PackageService = require('./packageService'); + const ProjectQuoteGenerationService = require('./projectQuoteGenerationService'); + const RoofGeometryService = require('./roofGeometryService'); + const BygmaImportService = require('./bygmaImportService'); + const ordrestyringService = require('./ordrestyringService'); + const graphqlClient = require('./graphqlClient'); + let LocalOrdrestyringAnalyzer = null; + + try { + LocalOrdrestyringAnalyzer = require('../../local_ordrestyring_analyzer'); + } catch (error) { + logger.warn('Local Ordrestyring analyzer is unavailable:', error.message); + } + + const dynamicImportService = new DynamicImportService(databaseService); + const orderStatusService = new OrderStatusService(); + const quoteTemplateService = new QuoteTemplateService(); + const pdfGenerationService = new PdfGenerationService(); + const realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService); + const enhancedOrderDataService = new EnhancedOrderDataService(databaseService); + const materialPackageService = new MaterialPackageService(databaseService); + const smartPackageManagementService = new SmartPackageManagementService(databaseService); + const planningService = new PlanningService(databaseService); + const ordrestyringSyncService = new OrdrestyringSyncService(); + + await databaseService.initialize(); + logger.info('Database initialized successfully'); + + try { + await openaiService.initialize(); + logger.info('OpenAI service initialized successfully'); + } catch (error) { + logger.warn('OpenAI service initialization failed:', error.message); + // Continue without OpenAI - fallback to static quotes only + } + + return { + databaseService, + openaiService, + webPriceService, + dynamicImportService, + orderStatusService, + quoteTemplateService, + pdfGenerationService, + realDataProjectSuggestionService, + enhancedOrderDataService, + materialPackageService, + smartPackageManagementService, + planningService, + ordrestyringSyncService, + // constructor-style deps used by handlers + PriceImportService, + BygmaPrisbogImportService, + PackageService, + ProjectQuoteGenerationService, + RoofGeometryService, + BygmaImportService, + LocalOrdrestyringAnalyzer, + ordrestyringService, + graphqlClient, + // infra pools + db, + healthPool + }; +} + +module.exports = { + bootstrapServiceContainer +}; diff --git a/backend/src/services/customerProjectService.js b/backend/src/services/customerProjectService.js index ab75b6e..e052531 100644 --- a/backend/src/services/customerProjectService.js +++ b/backend/src/services/customerProjectService.js @@ -13,6 +13,7 @@ class CustomerProjectService { const { projectName, customerName, + customerNumber, customerEmail, customerPhone, customerAddress, @@ -32,21 +33,49 @@ class CustomerProjectService { projectDescription }); - const query = ` - INSERT INTO customer_projects ( - project_name, customer_name, customer_email, - customer_phone, customer_address, project_description - ) VALUES (?, ?, ?, ?, ?, ?) - `; + let result; - const [result] = await this.db.pool.execute(query, [ - projectName, - customerName, - customerEmail || null, - customerPhone || null, - address || null, - projectDescription || null - ]); + try { + const query = ` + INSERT INTO customer_projects ( + project_name, customer_name, customer_number, customer_email, + customer_phone, customer_address, project_description + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + [result] = await this.db.pool.execute(query, [ + projectName, + customerName, + customerNumber || null, + customerEmail || null, + customerPhone || null, + address || null, + projectDescription || null + ]); + } catch (error) { + const isMissingCustomerNumberColumn = String(error.message || '').includes("Unknown column 'customer_number'"); + if (!isMissingCustomerNumberColumn) { + throw error; + } + + logger.warn('customer_number column missing in customer_projects - using legacy insert fallback'); + + const legacyQuery = ` + INSERT INTO customer_projects ( + project_name, customer_name, customer_email, + customer_phone, customer_address, project_description + ) VALUES (?, ?, ?, ?, ?, ?) + `; + + [result] = await this.db.pool.execute(legacyQuery, [ + projectName, + customerName, + customerEmail || null, + customerPhone || null, + address || null, + projectDescription || null + ]); + } console.log('✅ Project inserted with ID:', result.insertId); @@ -62,6 +91,8 @@ class CustomerProjectService { projectName, customer_name: customerName, customerName, + customer_number: customerNumber || null, + customerNumber: customerNumber || null, customer_email: customerEmail, customerEmail, customer_phone: customerPhone, @@ -159,6 +190,61 @@ class CustomerProjectService { } } + async updateProjectFields(projectId, updates = {}) { + try { + const fieldMap = { + project_name: 'project_name', + project_description: 'project_description', + customer_name: 'customer_name', + customer_number: 'customer_number', + customer_email: 'customer_email', + customer_phone: 'customer_phone', + customer_address: 'customer_address', + project_status: 'project_status', + selected_packages: 'selected_packages', + suggestion_snapshot: 'suggestion_snapshot', + input_normalization_log: 'input_normalization_log' + }; + + const setClauses = []; + const values = []; + + Object.entries(updates).forEach(([key, value]) => { + const column = fieldMap[key]; + if (!column) { + return; + } + + setClauses.push(`${column} = ?`); + values.push( + ['selected_packages', 'suggestion_snapshot', 'input_normalization_log'].includes(column) && + value && + typeof value === 'object' + ? JSON.stringify(value) + : value + ); + }); + + if (setClauses.length === 0) { + return false; + } + + values.push(projectId); + + const [result] = await this.db.pool.execute( + `UPDATE customer_projects + SET ${setClauses.join(', ')}, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + values + ); + + return result.affectedRows > 0; + } catch (error) { + logger.error('Error updating project fields:', error); + throw error; + } + } + // Hent alle projekter med pagination async getProjects(page = 1, limit = 20, status = null) { try { diff --git a/backend/src/services/databaseService.js b/backend/src/services/databaseService.js index a995c0e..4c24ba0 100644 --- a/backend/src/services/databaseService.js +++ b/backend/src/services/databaseService.js @@ -1,11 +1,49 @@ const mysql = require('mysql2/promise'); const logger = require('../utils/logger'); +const { ALL_PROJECT_STATUSES } = require('../constants/projectStatuses'); class DatabaseService { constructor() { this.pool = null; } + async ensureCustomerProjectsColumn(columnName, alterSql) { + const [columns] = await this.pool.query( + `SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'customer_projects' + AND COLUMN_NAME = ?`, + [columnName] + ); + + if (columns.length === 0) { + await this.pool.query(alterSql); + } + } + + async ensureCustomerProjectsStatusEnum(projectStatusEnumSql) { + const [columns] = await this.pool.query( + `SELECT COLUMN_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'customer_projects' + AND COLUMN_NAME = 'project_status'` + ); + + const currentType = String(columns[0]?.COLUMN_TYPE || '').toLowerCase(); + const missingStatuses = ALL_PROJECT_STATUSES.filter( + (status) => !currentType.includes(`'${status.toLowerCase()}'`) + ); + + if (missingStatuses.length > 0) { + await this.pool.query(` + ALTER TABLE customer_projects + MODIFY COLUMN project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft' + `); + } + } + async initialize() { try { // SECURITY: Require database password in environment variables @@ -659,25 +697,50 @@ class DatabaseService { `); // Create customer projects system tables + const projectStatusEnumSql = ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', '); + await this.pool.execute(` CREATE TABLE IF NOT EXISTS customer_projects ( id INT AUTO_INCREMENT PRIMARY KEY, project_name VARCHAR(255) NOT NULL, customer_name VARCHAR(255) NOT NULL, + customer_number VARCHAR(50), customer_email VARCHAR(255), customer_phone VARCHAR(50), customer_address TEXT, project_description TEXT, - project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft', + selected_packages JSON, + suggestion_snapshot JSON, + input_normalization_log JSON, + project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (project_status), INDEX idx_customer (customer_name), + INDEX idx_customer_number (customer_number), INDEX idx_created (created_at) ) `); + await this.ensureCustomerProjectsColumn( + 'customer_number', + 'ALTER TABLE customer_projects ADD COLUMN customer_number VARCHAR(50) AFTER customer_name' + ); + await this.ensureCustomerProjectsColumn( + 'selected_packages', + 'ALTER TABLE customer_projects ADD COLUMN selected_packages JSON AFTER project_description' + ); + await this.ensureCustomerProjectsColumn( + 'suggestion_snapshot', + 'ALTER TABLE customer_projects ADD COLUMN suggestion_snapshot JSON AFTER selected_packages' + ); + await this.ensureCustomerProjectsColumn( + 'input_normalization_log', + 'ALTER TABLE customer_projects ADD COLUMN input_normalization_log JSON AFTER suggestion_snapshot' + ); + await this.ensureCustomerProjectsStatusEnum(projectStatusEnumSql); + await this.pool.execute(` CREATE TABLE IF NOT EXISTS roof_geometry ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/src/services/materialPriceStatusService.js b/backend/src/services/materialPriceStatusService.js new file mode 100644 index 0000000..13cd9ed --- /dev/null +++ b/backend/src/services/materialPriceStatusService.js @@ -0,0 +1,180 @@ +const logger = require('../utils/logger'); + +class MaterialPriceStatusService { + constructor(databaseService) { + this.db = databaseService; + } + + async getStatus() { + const [bygma, stark] = await Promise.all([ + this.getBygmaStatus(), + this.getStarkStatus() + ]); + + const sources = [bygma, stark].filter(Boolean); + const primarySource = sources + .filter((source) => source.lastImportAt || source.latestPriceAt) + .sort((left, right) => { + const leftTs = new Date(left.lastImportAt || left.latestPriceAt || 0).getTime(); + const rightTs = new Date(right.lastImportAt || right.latestPriceAt || 0).getTime(); + return rightTs - leftTs; + })[0] || null; + + return { + generatedAt: new Date().toISOString(), + primarySource, + sources + }; + } + + async getBygmaStatus() { + const latestImport = await this.safeFirst(` + SELECT + started_at, + completed_at, + status, + imported_by, + filename, + updated_prices, + successful_rows + FROM bygma_import_log + ORDER BY COALESCE(completed_at, started_at) DESC + LIMIT 1 + `); + + const latestPricePoint = await this.safeFirst(` + SELECT MAX(import_date) AS latest_price_at + FROM bygma_price_history + `); + + if (!latestImport && !latestPricePoint?.latest_price_at) { + return { + source: 'Bygma', + available: false, + freshness: 'missing', + statusLabel: 'Ingen Bygma prisliste fundet' + }; + } + + return this.buildSourceStatus('Bygma', latestImport, latestPricePoint?.latest_price_at); + } + + async getStarkStatus() { + const latestImport = await this.safeFirst(` + SELECT + started_at, + completed_at, + status, + imported_by, + file_name AS filename, + updated_items, + total_items + FROM import_logs + WHERE source_system = 'Stark' + ORDER BY COALESCE(completed_at, started_at) DESC + LIMIT 1 + `); + + const latestPricePoint = await this.safeFirst(` + SELECT MAX(last_updated) AS latest_price_at + FROM stark_materials_cache + `); + + if (!latestImport && !latestPricePoint?.latest_price_at) { + return { + source: 'Stark', + available: false, + freshness: 'missing', + statusLabel: 'Ingen Stark prisliste fundet' + }; + } + + return this.buildSourceStatus('Stark', latestImport, latestPricePoint?.latest_price_at); + } + + buildSourceStatus(source, latestImport, latestPriceAt) { + const lastImportAt = latestImport?.completed_at || latestImport?.started_at || latestPriceAt || null; + const ageDays = this.calculateAgeDays(lastImportAt); + const freshness = this.getFreshness(ageDays); + + return { + source, + available: true, + freshness, + statusLabel: this.getStatusLabel(source, freshness, lastImportAt), + importStatus: latestImport?.status || 'unknown', + lastImportAt, + latestPriceAt: latestPriceAt || null, + importedBy: latestImport?.imported_by || null, + filename: latestImport?.filename || null, + changeCount: latestImport?.updated_prices ?? latestImport?.updated_items ?? null, + rowCount: latestImport?.successful_rows ?? latestImport?.total_items ?? null, + ageDays + }; + } + + calculateAgeDays(value) { + if (!value) { + return null; + } + + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) { + return null; + } + + return Math.floor((Date.now() - timestamp) / 86400000); + } + + getFreshness(ageDays) { + if (ageDays === null) { + return 'missing'; + } + if (ageDays <= 7) { + return 'fresh'; + } + if (ageDays <= 30) { + return 'aging'; + } + return 'stale'; + } + + getStatusLabel(source, freshness, lastImportAt) { + if (!lastImportAt) { + return `Ingen ${source} prisliste fundet`; + } + + if (freshness === 'fresh') { + return `${source} prisliste er opdateret`; + } + + if (freshness === 'aging') { + return `${source} prisliste er ved at blive gammel`; + } + + return `${source} prisliste trænger til opdatering`; + } + + async safeFirst(query, params = []) { + try { + const rows = await this.db.query(query, params); + return rows[0] || null; + } catch (error) { + const message = String(error.message || '').toLowerCase(); + if ( + message.includes('doesn\'t exist') || + message.includes('unknown table') || + message.includes('unknown column') + ) { + return null; + } + + logger.warn('Failed to read material price status source', { + error: error.message + }); + return null; + } + } +} + +module.exports = MaterialPriceStatusService; diff --git a/backend/src/services/orderSuggestionService.js b/backend/src/services/orderSuggestionService.js new file mode 100644 index 0000000..d388743 --- /dev/null +++ b/backend/src/services/orderSuggestionService.js @@ -0,0 +1,931 @@ +const logger = require('../utils/logger'); +const { OFFER_HISTORY_PROJECT_STATUSES } = require('../constants/projectStatuses'); + +class OrderSuggestionService { + constructor(databaseService) { + this.db = databaseService; + } + + tokenize(value) { + return (value || '') + .toLowerCase() + .split(/[\s,.;:/()_-]+/) + .map(token => token.trim()) + .filter(token => token.length >= 3); + } + + normalizeText(value) { + return (value || '').toLowerCase().trim(); + } + + parseDate(value) { + const date = value ? new Date(value) : null; + return Number.isNaN(date?.getTime()) ? null : date; + } + + parseJson(value, fallback = null) { + if (!value) { + return fallback; + } + + if (typeof value === 'object') { + return value; + } + + try { + return JSON.parse(value); + } catch (error) { + return fallback; + } + } + + normalizeRoofType(value) { + const normalized = this.normalizeText(value) + .replace(/æ/g, 'ae') + .replace(/ø/g, 'oe') + .replace(/å/g, 'aa'); + + if (!normalized) { + return ''; + } + + if (normalized.includes('tagpap') || normalized.includes('flad')) { + return 'tagpap'; + } + + if (normalized.includes('b7') || normalized.includes('eternit')) { + return 'b7'; + } + + if (normalized.includes('tegl') || normalized.includes('betontegl') || normalized.includes('vingetegl')) { + return 'tegl'; + } + + if (normalized.includes('skorsten') || normalized.includes('inddaekning')) { + return 'skorsten'; + } + + if (normalized.includes('velux') || normalized.includes('tagvindue')) { + return 'velux'; + } + + if (normalized.includes('facade') || normalized.includes('beklaedning')) { + return 'facade'; + } + + if (normalized.includes('sadeltag') || normalized.includes('skraat')) { + return 'skraat_tag'; + } + + return normalized; + } + + getPackageCatalog() { + return [ + { + packageId: 'komplet_tagrenovering', + name: 'Komplet Tagrenovering', + keywords: ['nyt tag', 'komplet', 'tagrenovering', 'udskiftning af tag', 'renovering af tag'] + }, + { + packageId: 'b7_tag_udskiftning', + name: 'B7 Tag Udskiftning - Komplet', + keywords: ['b7', 'eternit', 'tagplader'] + }, + { + packageId: 'tegl_tag_renovering', + name: 'Tegltag Renovering', + keywords: ['tegl', 'tegltag', 'betontegl', 'vingetegl', 'rygning'] + }, + { + packageId: 'tagpap_renovering', + name: 'Tagpap Renovering', + keywords: ['tagpap', 'pap', 'fladt tag', 'fladtag'] + }, + { + packageId: 'tagreparation_skorsten', + name: 'Tagreparation Ved Skorsten', + keywords: ['skorsten', 'inddækning', 'inddaekning', 'tagreparation', 'utæt', 'utæthed', 'kip'] + }, + { + packageId: 'velux_tagvindue', + name: 'Velux / Tagvindue', + keywords: ['velux', 'tagvindue', 'ovenlys'] + }, + { + packageId: 'facade_træ', + name: 'Facade Træbeklædning', + keywords: ['facade', 'beklædning', 'træbeklædning'] + } + ]; + } + + inferRecommendedPackage(project, localTopMatch, orderTopMatch) { + const localPackages = this.parseJson(localTopMatch?.selected_packages, null); + const selectedPackage = Array.isArray(localPackages) ? localPackages[0] : localPackages; + if (selectedPackage?.id || selectedPackage?.name) { + return { + packageId: selectedPackage.id || null, + name: selectedPackage.name || 'Historisk valgt pakke', + source: 'historical_project', + reason: 'Brugt på lignende lokalt projekt' + }; + } + + const text = [ + project.project_name, + project.project_description, + localTopMatch?.project_name, + localTopMatch?.project_description, + orderTopMatch?.description, + orderTopMatch?.remarks + ].join(' ').toLowerCase(); + + const inferredRoofType = this.normalizeRoofType([ + project.roof_type, + project.project_name, + project.project_description, + localTopMatch?.project_description, + orderTopMatch?.description + ].join(' ')); + + const roofTypeAliases = { + b7: ['b7_tag_udskiftning'], + tegl: ['tegl_tag_renovering'], + tagpap: ['tagpap_renovering'], + skorsten: ['tagreparation_skorsten'], + velux: ['velux_tagvindue'] + }; + + const directRoofTypeMatch = this.getPackageCatalog().find(item => + roofTypeAliases[inferredRoofType]?.includes(item.packageId) + ); + + if (directRoofTypeMatch) { + return { + packageId: directRoofTypeMatch.packageId, + name: directRoofTypeMatch.name, + source: 'inferred', + reason: 'Udledt fra tagtype og historiske nøgleord' + }; + } + + const match = this.getPackageCatalog().find(item => + item.keywords.some(keyword => text.includes(keyword)) + ); + + if (!match) { + return null; + } + + return { + packageId: match.packageId, + name: match.name, + source: 'inferred', + reason: 'Udledt fra projekttype og historiske matches' + }; + } + + buildSuggestionConfidence(topMatch, matchCount) { + if (!topMatch) { + return 'low'; + } + + if (topMatch.sourceType === 'local_project' && topMatch.relevanceScore >= 65) { + return 'high'; + } + + if (topMatch.sameCustomer && matchCount >= 2) { + return 'high'; + } + + if (topMatch.relevanceScore >= 40) { + return 'medium'; + } + + return 'low'; + } + + roundQuantity(value) { + return Math.round((parseFloat(value) || 0) * 100) / 100; + } + + scaleFactor(targetArea, sourceArea) { + const target = parseFloat(targetArea) || 0; + const source = parseFloat(sourceArea) || 0; + if (!target || !source || source <= 0) { + return 1; + } + + const ratio = target / source; + return Math.min(Math.max(ratio, 0.35), 3.5); + } + + scaleMaterialQuantity(material, factor) { + const unit = this.normalizeText(material.unit); + + if (unit.includes('pakke') || unit.includes('stk')) { + return Math.max(1, Math.round((parseFloat(material.quantity) || 0) * factor)); + } + + return this.roundQuantity((parseFloat(material.quantity) || 0) * factor); + } + + normalizeMaterialDraft(material, factor = 1, reason = 'Fra historisk match') { + return { + name: material.name || material.material_name || 'Historisk materiale', + quantity: this.scaleMaterialQuantity(material, factor), + unit: material.unit || 'stk', + unitPrice: parseFloat(material.unitPrice ?? material.unit_price ?? 0) || 0, + category: material.category || material.material_category || 'Historik', + calculation: reason + }; + } + + normalizeLaborDraft(task, factor = 1, fallbackRate = 580) { + const totalHours = this.roundQuantity((parseFloat(task.totalHours ?? task.total_work_hours ?? task.fixedHours ?? task.hours ?? 0) || 0) * factor); + const rate = parseFloat(task.rate ?? task.hourly_rate ?? fallbackRate) || fallbackRate; + + return { + name: task.name || task.task || 'Historisk opgave', + description: task.description || task.notes || 'Udledt fra historisk tilbud', + totalHours, + rate, + totalCost: this.roundQuantity(totalHours * rate), + category: task.category || 'Historik' + }; + } + + buildLaborTasksFromBreakdown(laborRow) { + if (!laborRow) { + return []; + } + + const hourlyRate = parseFloat(laborRow.hourly_rate) || 580; + const breakdown = this.parseJson(laborRow.work_breakdown, []); + + if (Array.isArray(breakdown) && breakdown.length > 0) { + return breakdown.map(item => this.normalizeLaborDraft({ + name: item.task || item.name || item.category, + description: item.description || item.remark, + totalHours: item.totalHours ?? item.estimatedHours ?? item.hours, + rate: hourlyRate, + category: 'Historik' + }, 1, hourlyRate)); + } + + const totalWorkHours = parseFloat(laborRow.total_work_hours) || 0; + if (totalWorkHours <= 0) { + return []; + } + + return [this.normalizeLaborDraft({ + name: 'Samlet arbejdsestimat', + description: laborRow.notes || 'Opsummeret fra historisk projekt', + totalHours: totalWorkHours, + rate: hourlyRate, + category: 'Historik' + }, 1, hourlyRate)]; + } + + summarizeHistoryContext(localTopMatch, orderTopMatch) { + const sourceProjectIds = localTopMatch ? [localTopMatch.projectId] : []; + const sourceCaseNumbers = orderTopMatch ? [orderTopMatch.caseNumber] : []; + + return { + sourceProjectIds, + sourceCaseNumbers, + localProjectName: localTopMatch?.projectName || null, + orderCaseNumber: orderTopMatch?.caseNumber || null + }; + } + + async getProjectContext(projectId) { + const [rows] = await this.db.pool.execute( + ` + SELECT + cp.id, + cp.project_name, + cp.project_description, + cp.customer_name, + cp.customer_number, + cp.selected_packages, + cp.suggestion_snapshot, + rg.roof_type, + rg.total_area, + rg.roof_pitch + FROM customer_projects cp + LEFT JOIN roof_geometry rg ON rg.project_id = cp.id + WHERE cp.id = ? + `, + [projectId] + ); + + if (!rows[0]) { + const error = new Error('Projekt ikke fundet'); + error.status = 404; + throw error; + } + + return rows[0]; + } + + async getLocalCandidateProjects(project, tokens, candidateLimit) { + const params = [project.id]; + const whereClauses = []; + + if (project.customer_number) { + whereClauses.push('cp.customer_number = ?'); + params.push(project.customer_number); + } + + if (project.customer_name) { + whereClauses.push('LOWER(COALESCE(cp.customer_name, \'\')) LIKE ?'); + params.push(`%${this.normalizeText(project.customer_name)}%`); + } + + if (project.roof_type) { + whereClauses.push('LOWER(COALESCE(rg.roof_type, \'\')) = ?'); + params.push(this.normalizeText(project.roof_type)); + } + + tokens.slice(0, 6).forEach(token => { + whereClauses.push( + '(LOWER(COALESCE(cp.project_name, \'\')) LIKE ? OR LOWER(COALESCE(cp.project_description, \'\')) LIKE ?)' + ); + params.push(`%${token}%`, `%${token}%`); + }); + + if (whereClauses.length === 0) { + return []; + } + + const [rows] = await this.db.pool.execute( + ` + SELECT + cp.id AS project_id, + cp.project_name, + cp.project_description, + cp.customer_name, + cp.customer_number, + cp.project_status, + cp.selected_packages, + cp.created_at, + cp.updated_at, + rg.roof_type, + rg.total_area, + rg.roof_pitch, + pl.total_work_hours, + pl.hourly_rate, + pl.work_breakdown, + pl.notes AS labor_notes + FROM customer_projects cp + LEFT JOIN roof_geometry rg ON rg.project_id = cp.id + LEFT JOIN project_labor pl ON pl.project_id = cp.id + WHERE cp.id <> ? AND (${whereClauses.join(' OR ')}) + ORDER BY cp.updated_at DESC + LIMIT ? + `, + [...params, candidateLimit] + ); + + if (rows.length === 0) { + return []; + } + + const projectIds = rows.map(row => row.project_id); + const placeholders = projectIds.map(() => '?').join(', '); + const [materials] = await this.db.pool.execute( + ` + SELECT + project_id, + material_name, + material_category, + quantity, + unit, + unit_price + FROM project_materials + WHERE project_id IN (${placeholders}) + ORDER BY project_id, total_price DESC, created_at DESC + `, + projectIds + ); + + const [quotes] = await this.db.pool.execute( + ` + SELECT project_id, quote_text, created_at + FROM generated_quotes + WHERE project_id IN (${placeholders}) + ORDER BY created_at DESC + `, + projectIds + ); + + const materialsByProject = new Map(); + materials.forEach(material => { + if (!materialsByProject.has(material.project_id)) { + materialsByProject.set(material.project_id, []); + } + materialsByProject.get(material.project_id).push(material); + }); + + const quoteByProject = new Map(); + quotes.forEach(quote => { + if (!quoteByProject.has(quote.project_id)) { + quoteByProject.set(quote.project_id, quote); + } + }); + + return rows.map(row => ({ + ...row, + materials: materialsByProject.get(row.project_id) || [], + quote_text: quoteByProject.get(row.project_id)?.quote_text || '', + latest_quote_at: quoteByProject.get(row.project_id)?.created_at || null, + laborTasks: this.buildLaborTasksFromBreakdown(row) + })); + } + + calculateLocalCandidateScore(project, candidate, tokens) { + const projectText = [ + candidate.project_name, + candidate.project_description, + candidate.quote_text + ].join(' ').toLowerCase(); + const materialText = (candidate.materials || []) + .map(material => `${material.material_name || ''} ${material.material_category || ''}`) + .join(' ') + .toLowerCase(); + + const sourceRoofType = this.normalizeRoofType(candidate.roof_type); + const targetRoofType = this.normalizeRoofType(project.roof_type); + const sameCustomer = Boolean( + project.customer_number && + candidate.customer_number && + project.customer_number === candidate.customer_number + ); + + let score = 0; + const reasons = []; + + if (sameCustomer) { + score += 35; + reasons.push('Samme kunde i lokal historik'); + } else if ( + this.normalizeText(project.customer_name) && + this.normalizeText(candidate.customer_name).includes(this.normalizeText(project.customer_name)) + ) { + score += 12; + reasons.push('Kundenavn matcher'); + } + + if (targetRoofType && sourceRoofType && targetRoofType === sourceRoofType) { + score += 24; + reasons.push('Samme tagtype'); + } + + const targetArea = parseFloat(project.total_area) || 0; + const sourceArea = parseFloat(candidate.total_area) || 0; + if (targetArea > 0 && sourceArea > 0) { + const delta = Math.abs(targetArea - sourceArea) / Math.max(targetArea, 1); + if (delta <= 0.2) { + score += 18; + reasons.push('Lignende areal'); + } else if (delta <= 0.4) { + score += 8; + } + } + + let keywordHits = 0; + tokens.forEach(token => { + if (projectText.includes(token)) { + score += 6; + keywordHits += 1; + } + if (materialText.includes(token)) { + score += 4; + } + }); + + if (keywordHits > 0) { + reasons.push(`${keywordHits} nøgleord matcher`); + } + + if ((candidate.materials || []).length > 0) { + score += 10; + reasons.push('Har materialehistorik'); + } + + if ((candidate.laborTasks || []).length > 0 || (parseFloat(candidate.total_work_hours) || 0) > 0) { + score += 10; + reasons.push('Har timehistorik'); + } + + if (OFFER_HISTORY_PROJECT_STATUSES.includes(candidate.project_status)) { + score += 8; + reasons.push('Projektet blev brugt som tilbud'); + } + + const latestActivity = this.parseDate(candidate.latest_quote_at || candidate.updated_at || candidate.created_at); + if (latestActivity) { + const ageDays = Math.floor((Date.now() - latestActivity.getTime()) / 86400000); + if (ageDays <= 180) { + score += 8; + reasons.push('Nyere projekt'); + } else if (ageDays <= 365) { + score += 4; + } + } + + return { + sameCustomer, + relevanceScore: score, + matchReasons: reasons + }; + } + + async getOrderCandidateCases(project, tokens, candidateLimit) { + const whereClauses = []; + const params = []; + + if (project.customer_number) { + whereClauses.push('c.customer_number = ?'); + params.push(project.customer_number); + } + + if (project.customer_name) { + whereClauses.push('LOWER(COALESCE(d.customer_name, \'\')) LIKE ?'); + params.push(`%${this.normalizeText(project.customer_name)}%`); + } + + tokens.slice(0, 6).forEach(token => { + whereClauses.push( + '(LOWER(COALESCE(c.description, \'\')) LIKE ? OR LOWER(COALESCE(c.remarks, \'\')) LIKE ?)' + ); + params.push(`%${token}%`, `%${token}%`); + }); + + if (whereClauses.length === 0) { + return []; + } + + const [rows] = await this.db.pool.execute( + ` + SELECT + c.case_number, + c.customer_number, + d.customer_name, + c.description, + c.remarks, + c.creation_date, + c.status, + COALESCE(SUM(h.duration_hours), 0) AS total_hours, + COUNT(h.id) AS hour_entries + FROM ordrestyring_local.cases c + LEFT JOIN ordrestyring_local.debtors d + ON d.customer_number = c.customer_number + LEFT JOIN ordrestyring_local.hours h + ON h.case_number = c.case_number + WHERE ${whereClauses.join(' OR ')} + GROUP BY + c.case_number, + c.customer_number, + d.customer_name, + c.description, + c.remarks, + c.creation_date, + c.status + ORDER BY c.creation_date DESC + LIMIT ? + `, + [...params, candidateLimit] + ); + + return rows; + } + + calculateOrderCandidateScore(project, candidate, tokens) { + const descriptionText = this.normalizeText(candidate.description); + const remarksText = this.normalizeText(candidate.remarks); + const combinedText = `${descriptionText} ${remarksText}`.trim(); + const customerName = this.normalizeText(candidate.customer_name); + const projectCustomerName = this.normalizeText(project.customer_name); + + let score = 0; + const reasons = []; + + const sameCustomer = Boolean( + project.customer_number && + candidate.customer_number && + project.customer_number === candidate.customer_number + ); + + if (sameCustomer) { + score += 50; + reasons.push('Samme kunde i Ordrestyring'); + } else if (projectCustomerName && customerName && customerName.includes(projectCustomerName)) { + score += 15; + reasons.push('Kundenavn matcher'); + } + + let keywordHits = 0; + tokens.forEach(token => { + if (combinedText.includes(token)) { + score += 8; + keywordHits += 1; + } + }); + + if (keywordHits > 0) { + reasons.push(`${keywordHits} nøgleord matcher`); + } + + const creationDate = this.parseDate(candidate.creation_date); + if (creationDate) { + const ageDays = Math.floor((Date.now() - creationDate.getTime()) / 86400000); + if (ageDays <= 180) { + score += 10; + reasons.push('Nyere sag'); + } else if (ageDays <= 365) { + score += 5; + } + } + + if ((parseFloat(candidate.total_hours) || 0) > 0) { + score += 8; + reasons.push('Har timehistorik'); + } + + return { + sameCustomer, + relevanceScore: score, + matchReasons: reasons + }; + } + + buildSuggestedDescription(project, localTopMatch, orderTopMatch) { + const currentDescription = (project.project_description || '').trim(); + if (currentDescription) { + return currentDescription; + } + + if (localTopMatch?.project_description) { + return localTopMatch.project_description.trim(); + } + + if (orderTopMatch?.description) { + return orderTopMatch.description.trim(); + } + + return `Tilbud på ${project.project_name || 'projekt'}`; + } + + buildSuggestionSummary(localMatches, orderMatches, topMatch) { + if (!topMatch) { + return 'Ingen lokale historiske sager matcher godt nok endnu.'; + } + + const localCount = localMatches.length; + const orderCount = orderMatches.length; + const pieces = []; + + if (localCount > 0) { + pieces.push(`${localCount} lokale projekter`); + } + + if (orderCount > 0) { + pieces.push(`${orderCount} Ordrestyring-sager`); + } + + const customerPart = topMatch.sameCustomer ? ' fra samme kunde' : ''; + return `Baseret på ${pieces.join(' og ')}${customerPart}.`; + } + + buildClarifications(project, recommendedPackage, materialsDraft, laborDraft) { + const clarifications = []; + + if (!project.roof_type) { + clarifications.push('Vælg tagtype for mere præcis pakkeanbefaling.'); + } + + if (!(parseFloat(project.total_area) || 0)) { + clarifications.push('Indtast tagmål eller areal for bedre skalering af materialer og timer.'); + } + + if (!recommendedPackage) { + clarifications.push('Vælg en Smart Pakke manuelt hvis ingen historik matcher tydeligt.'); + } + + if (materialsDraft.length === 0) { + clarifications.push('Tilføj eller bekræft materialer før tilbuddet sendes.'); + } + + if (laborDraft.length === 0) { + clarifications.push('Tilføj eller bekræft timer/opgaver før tilbuddet sendes.'); + } + + return clarifications.slice(0, 3); + } + + buildWarnings(topMatch, materialsDraft, laborDraft) { + const warnings = []; + + if (!topMatch) { + warnings.push('Ingen stærk historisk reference fundet endnu.'); + } + + if (topMatch?.sourceType === 'orderstyring_case' && materialsDraft.length === 0) { + warnings.push('Ordrestyring-match giver primært tekst og timer, ikke komplet materialeliste.'); + } + + if (laborDraft.length === 0) { + warnings.push('Timer er ikke udfyldt automatisk og bør dobbelttjekkes.'); + } + + return warnings; + } + + mapMatch(match) { + if (match.sourceType === 'local_project') { + return { + sourceType: 'local_project', + projectId: match.projectId, + projectName: match.projectName, + customerName: match.customerName || '', + description: match.projectDescription || '', + creationDate: match.creationDate || null, + totalHours: this.roundQuantity(match.totalHours), + sameCustomer: match.sameCustomer, + relevanceScore: match.relevanceScore, + matchReasons: match.matchReasons + }; + } + + return { + sourceType: 'orderstyring_case', + caseNumber: match.caseNumber, + customerNumber: match.customerNumber, + customerName: match.customerName || '', + description: match.description || '', + remarks: match.remarks || '', + status: match.status || '', + creationDate: match.creationDate || null, + totalHours: this.roundQuantity(match.totalHours), + hourEntries: parseInt(match.hourEntries, 10) || 0, + sameCustomer: match.sameCustomer, + relevanceScore: match.relevanceScore, + matchReasons: match.matchReasons + }; + } + + async getOrderSuggestions(projectId, options = {}) { + const limit = Math.min(parseInt(options.limit, 10) || 5, 10); + const candidateLimit = Math.max(limit * 4, 12); + + try { + const project = await this.getProjectContext(projectId); + const tokens = this.tokenize( + `${project.project_name || ''} ${project.project_description || ''} ${project.roof_type || ''}` + ); + + const [localCandidates, orderCandidates] = await Promise.all([ + this.getLocalCandidateProjects(project, tokens, candidateLimit), + this.getOrderCandidateCases(project, tokens, candidateLimit) + ]); + + const localMatches = localCandidates + .map(candidate => { + const scored = this.calculateLocalCandidateScore(project, candidate, tokens); + return { + sourceType: 'local_project', + projectId: candidate.project_id, + projectName: candidate.project_name || '', + projectDescription: candidate.project_description || '', + customerName: candidate.customer_name || '', + customerNumber: candidate.customer_number || null, + projectStatus: candidate.project_status || '', + selected_packages: candidate.selected_packages || null, + creationDate: candidate.updated_at || candidate.created_at || null, + roofType: candidate.roof_type || '', + totalArea: parseFloat(candidate.total_area) || 0, + totalHours: parseFloat(candidate.total_work_hours) || 0, + hourlyRate: parseFloat(candidate.hourly_rate) || 580, + materials: candidate.materials || [], + laborTasks: candidate.laborTasks || [], + quoteText: candidate.quote_text || '', + ...scored + }; + }) + .filter(candidate => candidate.relevanceScore > 0) + .sort((left, right) => { + if (right.relevanceScore !== left.relevanceScore) { + return right.relevanceScore - left.relevanceScore; + } + + return new Date(right.creationDate || 0) - new Date(left.creationDate || 0); + }) + .slice(0, limit); + + const orderMatches = orderCandidates + .map(candidate => ({ + sourceType: 'orderstyring_case', + caseNumber: candidate.case_number, + customerNumber: candidate.customer_number, + customerName: candidate.customer_name || '', + description: candidate.description || '', + remarks: candidate.remarks || '', + status: candidate.status || '', + creationDate: candidate.creation_date || null, + totalHours: parseFloat(candidate.total_hours) || 0, + hourEntries: parseInt(candidate.hour_entries, 10) || 0, + ...this.calculateOrderCandidateScore(project, candidate, tokens) + })) + .filter(candidate => candidate.relevanceScore > 0) + .sort((left, right) => { + if (right.relevanceScore !== left.relevanceScore) { + return right.relevanceScore - left.relevanceScore; + } + + return new Date(right.creationDate || 0) - new Date(left.creationDate || 0); + }) + .slice(0, limit); + + const combinedMatches = [...localMatches, ...orderMatches] + .sort((left, right) => { + if (right.relevanceScore !== left.relevanceScore) { + return right.relevanceScore - left.relevanceScore; + } + + return new Date(right.creationDate || 0) - new Date(left.creationDate || 0); + }) + .slice(0, limit); + + const topMatch = combinedMatches[0] || null; + const localTopMatch = localMatches[0] || null; + const orderTopMatch = orderMatches[0] || null; + const projectScaleFactor = this.scaleFactor(project.total_area, localTopMatch?.totalArea); + + const materialsDraft = localTopMatch + ? localTopMatch.materials + .slice(0, 16) + .map(material => this.normalizeMaterialDraft(material, projectScaleFactor, 'Skaleret fra lignende historisk projekt')) + : []; + + const laborDraft = localTopMatch + ? localTopMatch.laborTasks.map(task => + this.normalizeLaborDraft(task, projectScaleFactor, localTopMatch.hourlyRate) + ) + : (orderTopMatch && orderTopMatch.totalHours > 0 + ? [this.normalizeLaborDraft({ + name: 'Historisk arbejdsestimat', + description: `Udledt fra Ordrestyring sag ${orderTopMatch.caseNumber}`, + totalHours: orderTopMatch.totalHours, + rate: 580, + category: 'Historik' + })] + : []); + + const recommendedPackage = this.inferRecommendedPackage(project, localTopMatch, orderTopMatch); + const suggestedDescription = this.buildSuggestedDescription(project, localTopMatch, orderTopMatch); + const averageHistoricalHours = combinedMatches.length > 0 + ? combinedMatches.reduce((sum, match) => sum + (parseFloat(match.totalHours) || 0), 0) / combinedMatches.length + : 0; + const historyContext = this.summarizeHistoryContext(localTopMatch, orderTopMatch); + const clarifications = this.buildClarifications(project, recommendedPackage, materialsDraft, laborDraft); + const warnings = this.buildWarnings(topMatch, materialsDraft, laborDraft); + + return { + projectId, + customerNumber: project.customer_number || null, + recommendation: { + confidence: this.buildSuggestionConfidence(topMatch, combinedMatches.length), + summary: this.buildSuggestionSummary(localMatches, orderMatches, topMatch), + suggestedDescription, + descriptionDraft: suggestedDescription, + averageHistoricalHours: this.roundQuantity(averageHistoricalHours), + recommendedPackage, + warnings, + clarifications + }, + materialsDraft, + laborDraft, + prefill: { + recommendedPackage, + suggestedDescription, + materials: materialsDraft, + laborTasks: laborDraft, + historyContext + }, + historyContext, + matches: combinedMatches.map(match => this.mapMatch(match)) + }; + } catch (error) { + logger.error('Error building Orderstyring suggestions', { + projectId, + error: error.message + }); + throw error; + } + } +} + +module.exports = OrderSuggestionService; diff --git a/backend/unified-server.js b/backend/unified-server.js index ea54ed6..e9c0a15 100644 --- a/backend/unified-server.js +++ b/backend/unified-server.js @@ -133,6 +133,7 @@ const healthPool = mysql.createPool({ // Import logger const logger = require('./src/utils/logger'); +const { bootstrapServiceContainer } = require('./src/services/bootstrapServiceContainer'); // WebSocket connection handling io.on('connection', (socket) => { @@ -324,64 +325,49 @@ const csvUpload = multer({ // Initialize backend services // Note: ordrestyringService deprecated - use GraphQL client instead let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, realDataProjectSuggestionService, enhancedOrderDataService, materialPackageService, smartPackageManagementService, planningService, ordrestyringSyncService; +let PriceImportServiceClass, BygmaPrisbogImportServiceClass, PackageServiceClass, ProjectQuoteGenerationServiceClass, RoofGeometryServiceClass, BygmaImportServiceClass, LocalOrdrestyringAnalyzerClass; +let ordrestyringService, graphqlClient; const initializeBackend = async () => { try { - // Import backend services (both are singletons) - databaseService = require('./src/services/databaseService'); - openaiService = require('./src/services/openaiService'); - webPriceService = require('./src/services/webPriceService'); + // Centralized service bootstrap container + const services = await bootstrapServiceContainer({ + logger, + db, + healthPool + }); - // Import new services - const DynamicImportService = require('./src/services/dynamicImportService'); - const OrderStatusService = require('./src/services/orderStatusService'); - const QuoteTemplateService = require('./src/services/quoteTemplateService'); - const PdfGenerationService = require('./src/services/pdfGenerationService'); - // const OrdrestyringService = require('./src/services/ordrestyringService'); // DEPRECATED - use GraphQL client - const RealDataProjectSuggestionService = require('./src/services/realDataProjectSuggestionService'); - const EnhancedOrderDataService = require('./src/services/enhancedOrderDataService'); - const MaterialPackageService = require('./src/services/materialPackageService'); - const SmartPackageManagementService = require('./src/services/smartPackageManagementService'); - const PlanningService = require('./src/services/planningService'); - const OrdrestyringSyncService = require('./src/services/ordrestyringSyncService'); - // const CalendarSyncService = require('./src/services/calendarSyncService'); // DEPRECATED - use GraphQL calendar route - - dynamicImportService = new DynamicImportService(databaseService); - orderStatusService = new OrderStatusService(); - // ordrestyringService = new OrdrestyringService(); // DEPRECATED - quoteTemplateService = new QuoteTemplateService(); - pdfGenerationService = new PdfGenerationService(); - realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService); - enhancedOrderDataService = new EnhancedOrderDataService(databaseService); - materialPackageService = new MaterialPackageService(databaseService); - smartPackageManagementService = new SmartPackageManagementService(databaseService); - planningService = new PlanningService(databaseService); - - // Initialize sync services - ordrestyringSyncService = new OrdrestyringSyncService(); - // calendarSyncService = new CalendarSyncService(); // DEPRECATED - using GraphQL calendar - - // Calendar sync disabled - using GraphQL calendar endpoint instead - // await calendarSyncService.startAutoSync(); - - // Initialize database - await databaseService.initialize(); - logger.info('Database initialized successfully'); + databaseService = services.databaseService; + 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.db = db; + app.locals.healthPool = healthPool; global.databaseService = databaseService; global.smartPackageManagementService = smartPackageManagementService; - // Initialize OpenAI service - try { - await openaiService.initialize(); - logger.info('OpenAI service initialized successfully'); - } catch (error) { - logger.warn('OpenAI service initialization failed:', error.message); - // Continue without OpenAI - fallback to static quotes only - } - return true; } catch (error) { logger.error('Failed to initialize backend:', error); @@ -390,36 +376,6 @@ const initializeBackend = async () => { }; // API Routes - simplified versions of main endpoints -app.get('/api/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - service: 'tilbudgivern-unified' - }); -}); - -// Database health endpoint (uses separate pool - promise-based) -app.get('/api/health/database', async (req, res) => { - try { - const conn = await healthPool.getConnection(); - const [result] = await conn.query('SELECT 1 as alive'); - conn.release(); - - res.json({ - status: 'ok', - database: 'connected', - connections: 1, - queries: Math.floor(Math.random() * 1000), - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(503).json({ - status: 'error', - database: 'disconnected', - error: error.message - }); - } -}); // Dashboard metrics endpoint (uses separate pool - promise-based) app.get('/api/dashboard/metrics', async (req, res) => { @@ -427,7 +383,7 @@ app.get('/api/dashboard/metrics', async (req, res) => { 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 ("quote_generated", "sent")'); + 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(); @@ -460,684 +416,11 @@ app.get('/api/dashboard/metrics', async (req, res) => { } }); -// Server metrics endpoint (CPU, Memory, Disk) -app.get('/api/health/server-metrics', (req, res) => { - try { - const os = require('os'); - - // CPU usage - const cpus = os.cpus(); - const avgLoad = os.loadavg(); - const cpuUsage = Math.min(Math.round((avgLoad[0] / cpus.length) * 100), 100); - - // Memory usage - const totalMem = os.totalmem(); - const freeMem = os.freemem(); - const usedMem = totalMem - freeMem; - const memoryUsage = Math.round((usedMem / totalMem) * 100); - - // Disk usage (using df command on Linux) - const fs = require('fs'); - let diskUsage = 0; - try { - const { execSync } = require('child_process'); - const result = execSync('df -h / | tail -1').toString(); - const parts = result.split(/\s+/); - if (parts.length >= 5) { - diskUsage = parseInt(parts[4]); - } - } catch (e) { - // Fallback to simulated value if df fails - diskUsage = Math.floor(Math.random() * 70) + 20; - } - - res.json({ - success: true, - metrics: { - cpu: cpuUsage, - memory: memoryUsage, - disk: diskUsage, - timestamp: new Date().toISOString() - } - }); - } catch (error) { - res.json({ - success: true, - metrics: { - cpu: 0, - memory: 0, - disk: 0, - error: error.message - } - }); - } -}); - -// PM2 logs endpoint -app.get('/api/health/pm2-logs', (req, res) => { - try { - const pm2 = require('pm2'); - - pm2.connect((err) => { - if (err) { - return res.json({ - success: false, - error: 'Could not connect to PM2', - logs: [] - }); - } - - pm2.list((err, processes) => { - if (err) { - pm2.disconnect(); - return res.json({ - success: false, - error: 'Could not get processes', - logs: [] - }); - } - - const logs = processes - .filter(p => p.name.includes('tilbudgivern')) - .map(p => ({ - name: p.name, - pid: p.pid, - status: p.pm2_env.status, - uptime: p.pm2_env.pm_uptime ? new Date(p.pm2_env.pm_uptime) : null, - restarts: p.pm2_env.restart_time || 0, - memory: Math.round((p.monit?.memory || 0) / 1024 / 1024), // MB - cpu: p.monit?.cpu || 0, - instances: p.pm2_env.instances || 1 - })); - - pm2.disconnect(); - - res.json({ - success: true, - logs: logs - }); - }); - }); - } catch (error) { - res.json({ - success: false, - error: error.message, - logs: [] - }); - } -}); - -// Enhanced SQL status endpoint -app.get('/api/health/sql-status', (req, res) => { - try { - healthPool.getConnection((err, connection) => { - if (err) { - return res.status(503).json({ - status: 'error', - database: 'disconnected', - error: err.message - }); - } - - connection.query('SHOW PROCESSLIST', (error, results) => { - connection.release(); - - if (error) { - return res.status(503).json({ - status: 'error', - database: 'query_failed', - error: error.message - }); - } - - const activeQueries = results.filter(p => p.Command !== 'Sleep').length; - const totalConnections = results.length; - - // Get database size - connection = null; - healthPool.getConnection((err2, conn) => { - if (err2) { - return res.json({ - status: 'warning', - database: 'connected', - activeQueries, - totalConnections, - timestamp: new Date().toISOString() - }); - } - - conn.query(` - SELECT - table_schema AS 'DB', - ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size_MB' - FROM information_schema.TABLES - WHERE table_schema = DATABASE() - GROUP BY table_schema - `, (err3, sizeResults) => { - conn.release(); - - const dbSize = sizeResults?.[0]?.Size_MB || 0; - - res.json({ - status: 'ok', - database: 'connected', - activeQueries, - totalConnections, - databaseSize_MB: dbSize, - timestamp: new Date().toISOString() - }); - }); - }); - }); - }); - } catch (error) { - res.status(500).json({ - status: 'error', - database: 'error', - error: error.message - }); - } -}); - // =================================== // ANALYTICS API - GraphQL INTEGRATION // =================================== -const graphqlClient = require('./src/services/graphqlClient'); - -// KPIs endpoint - Get analytics from GraphQL -app.get('/api/analytics/kpis', async (req, res) => { - try { - console.log('📊 Fetching analytics KPIs via GraphQL...'); - - // Get cases with basic info (limit to max 200) - const CASES_QUERY = ` - query GetCasesForAnalytics { - cases(pagination: { cursor: null, limit: 200 }) { - items { - id - caseNumber - offerTotal - customer { - id - name - } - } - } - } - `; - - const casesData = await graphqlClient.request(CASES_QUERY); - const cases = casesData.cases?.items || []; - - // Calculate KPIs - let totalRevenue = 0; - const customers = new Set(); - - cases.forEach(c => { - totalRevenue += c.offerTotal || 0; - if (c.customer?.id) { - customers.add(c.customer.id); - } - }); - - // Get users count - const USERS_QUERY = ` - query GetUsers { - users(pagination: { cursor: null, limit: 100 }) { - items { - id - active - } - } - } - `; - - const usersData = await graphqlClient.request(USERS_QUERY); - const activeEmployees = (usersData.users?.items || []).filter(u => u.active).length; - - // Get detailed economy data for profit margin calculation - const ECONOMY_QUERY = ` - query GetEconomyData { - cases(pagination: { cursor: null, limit: 200 }) { - items { - economy { - hoursSalesprice - hoursCostprice - materialsSalesprice - materialsCostprice - } - } - } - } - `; - - const economyData = await graphqlClient.request(ECONOMY_QUERY); - const economyCases = economyData.cases?.items || []; - - // Calculate actual profit margins - let totalHoursSales = 0; - let totalHoursCost = 0; - let totalMaterialsSales = 0; - let totalMaterialsCost = 0; - - economyCases.forEach(c => { - const eco = c.economy || {}; - totalHoursSales += eco.hoursSalesprice || 0; - totalHoursCost += eco.hoursCostprice || 0; - totalMaterialsSales += eco.materialsSalesprice || 0; - totalMaterialsCost += eco.materialsCostprice || 0; - }); - - const totalSales = totalHoursSales + totalMaterialsSales; - const totalCost = totalHoursCost + totalMaterialsCost; - const totalProfit = totalSales - totalCost; - - // Calculate margins - const hoursMargin = totalHoursSales > 0 ? ((totalHoursSales - totalHoursCost) / totalHoursSales * 100) : 0; - const materialsMargin = totalMaterialsSales > 0 ? ((totalMaterialsSales - totalMaterialsCost) / totalMaterialsSales * 100) : 0; - const overallMargin = totalSales > 0 ? (totalProfit / totalSales * 100) : 0; - - // Estimate hours based on actual cost (assuming ~450 DKK/hour cost rate) - const totalHours = Math.round(totalHoursCost / 450); - - res.json({ - success: true, - data: { - totalProjects: cases.length, - totalRevenue: Math.round(totalRevenue), - activeCustomers: customers.size, - activeEmployees: activeEmployees, - avgProfitMargin: Math.round(overallMargin * 10) / 10, - hoursMargin: Math.round(hoursMargin * 10) / 10, - materialsMargin: Math.round(materialsMargin * 10) / 10, - totalHours: totalHours, - avgProjectValue: cases.length > 0 ? Math.round(totalRevenue / cases.length) : 0, - totalProfit: Math.round(totalProfit), - economyBreakdown: { - hours: { - sales: Math.round(totalHoursSales), - cost: Math.round(totalHoursCost), - profit: Math.round(totalHoursSales - totalHoursCost), - margin: Math.round(hoursMargin * 10) / 10 - }, - materials: { - sales: Math.round(totalMaterialsSales), - cost: Math.round(totalMaterialsCost), - profit: Math.round(totalMaterialsSales - totalMaterialsCost), - margin: Math.round(materialsMargin * 10) / 10 - } - }, - targets: { - hoursMargin: 2, - materialsMargin: 20, - note: 'Ledelsens forventede profit margins' - }, - ordrestyringReportUrl: '/api/analytics/ordrestyring-report', - lastUpdated: new Date().toISOString() - } - }); - } catch (error) { - console.error('Analytics KPIs error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved hentning af KPIs', details: error.message }); - } -}); - -// Employees endpoint - Get employees from GraphQL -app.get('/api/analytics/employees', async (req, res) => { - try { - console.log('👥 Fetching employees via GraphQL...'); - - const USERS_QUERY = ` - query GetUsers { - users(pagination: { cursor: null, limit: 100 }) { - items { - id - fullName - email - active - } - } - } - `; - - const usersData = await graphqlClient.request(USERS_QUERY); - const users = usersData.users?.items || []; - - res.json({ - success: true, - data: users.filter(u => u.active), - count: users.filter(u => u.active).length - }); - } catch (error) { - console.error('Analytics employees error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved hentning af medarbejdere', details: error.message }); - } -}); - -// Customers endpoint - Get customers from GraphQL -app.get('/api/analytics/customers', async (req, res) => { - try { - const { top } = req.query; - // Validate pagination limit (GraphQL requires 1-200) - const limit = Math.min(Math.max(parseInt(top) || 100, 1), 200); - - console.log(`🏢 Fetching top ${limit} customers via GraphQL...`); - - const CUSTOMERS_QUERY = ` - query GetCustomers($limit: Int!) { - customers(pagination: { cursor: null, limit: $limit }) { - items { - id - name - createdAt - } - } - } - `; - - const customersData = await graphqlClient.request(CUSTOMERS_QUERY, { limit }); - const customers = customersData.customers?.items || []; - - res.json({ - success: true, - data: customers, - count: customers.length - }); - } catch (error) { - console.error('Analytics customers error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved hentning af kunder', details: error.message }); - } -}); - - -// Projects endpoint - Get projects with analytics from GraphQL -app.get('/api/analytics/projects', async (req, res) => { - try { - const { limit } = req.query; - // Validate pagination limit (GraphQL requires 1-200) - const queryLimit = Math.min(Math.max(parseInt(limit) || 100, 1), 200); - - console.log(`📁 Fetching ${queryLimit} projects via GraphQL...`); - - const PROJECTS_QUERY = ` - query GetProjectsForAnalytics($limit: Int!) { - cases(pagination: { cursor: null, limit: $limit }) { - items { - id - caseNumber - description - status { - id - text - } - economy { - hoursSalesprice - hoursCostprice - materialsSalesprice - materialsCostprice - } - customer { - id - name - } - } - } - } - `; - - const projectsData = await graphqlClient.request(PROJECTS_QUERY, { limit: queryLimit }); - const cases = projectsData.cases?.items || []; - - // Calculate analytics for each project - const enrichedProjects = cases.map(c => { - const hoursRevenue = c.economy?.hoursSalesprice || 0; - const hoursCost = c.economy?.hoursCostprice || 0; - const materialsRevenue = c.economy?.materialsSalesprice || 0; - const materialsCost = c.economy?.materialsCostprice || 0; - - const totalRevenue = hoursRevenue + materialsRevenue; - const totalCost = hoursCost + materialsCost; - const totalProfit = totalRevenue - totalCost; - const profitMargin = totalRevenue > 0 ? (totalProfit / totalRevenue * 100) : 0; - const estimatedHours = hoursCost / 450; // Assuming 450 DKK/hour cost - - return { - project_id: c.id, - case_number: c.caseNumber, - project_name: c.description, - customer_name: c.customer?.name || 'Unknown', - status: c.status?.text || 'Unknown', - total_revenue: Math.round(totalRevenue), - total_cost: Math.round(totalCost), - total_profit: Math.round(totalProfit), - profit_margin: Math.round(profitMargin * 10) / 10, - estimated_hours: Math.round(estimatedHours * 10) / 10, - hours_revenue: Math.round(hoursRevenue), - materials_revenue: Math.round(materialsRevenue) - }; - }); - - res.json({ - success: true, - data: enrichedProjects, - count: enrichedProjects.length - }); - } catch (error) { - console.error('Analytics projects error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved hentning af projekter', details: error.message }); - } -}); - -// Individual project endpoint - Get single project details via GraphQL -app.get('/api/analytics/projects/:id', async (req, res) => { - try { - const { id } = req.params; - console.log(`📁 Fetching project ${id} details via GraphQL...`); - - // Try to determine if id is a case number or project ID - // Case numbers are typically numeric strings like "12345" - // Project IDs might be UUIDs or different format - - const PROJECT_QUERY = ` - query GetProjectDetails($caseNumber: String!) { - caseByCaseNumber(caseNumber: $caseNumber) { - id - caseNumber - description - status - economy { - hoursSalesprice - hoursCostprice - materialsSalesprice - materialsCostprice - } - customer { - id - name - customerNumber - email - } - } - } - `; - - try { - const projectData = await graphqlClient.request(PROJECT_QUERY, { caseNumber: id }); - const project = projectData.caseByCaseNumber; - - if (!project) { - return res.status(404).json({ success: false, error: 'Project ikke fundet' }); - } - - const hoursRevenue = project.economy?.hoursSalesprice || 0; - const hoursCost = project.economy?.hoursCostprice || 0; - const materialsRevenue = project.economy?.materialsSalesprice || 0; - const materialsCost = project.economy?.materialsCostprice || 0; - - const totalRevenue = hoursRevenue + materialsRevenue; - const totalCost = hoursCost + materialsCost; - const totalProfit = totalRevenue - totalCost; - const profitMargin = totalRevenue > 0 ? (totalProfit / totalRevenue * 100) : 0; - - res.json({ - success: true, - data: { - project: { - id: project.id, - case_number: project.caseNumber, - name: project.description, - status: project.status, - customer: project.customer - }, - analytics: { - total_revenue: Math.round(totalRevenue), - total_cost: Math.round(totalCost), - total_profit: Math.round(totalProfit), - profit_margin: Math.round(profitMargin * 10) / 10, - hours_revenue: Math.round(hoursRevenue), - hours_cost: Math.round(hoursCost), - materials_revenue: Math.round(materialsRevenue), - materials_cost: Math.round(materialsCost), - estimated_hours: Math.round((hoursCost / 450) * 10) / 10 - } - } - }); - } catch (graphqlError) { - console.error('GraphQL project query error:', graphqlError.message); - // If case number not found in Ordrestyring, return 404 - res.status(404).json({ - success: false, - error: 'Project ikke fundet i Ordrestyring', - details: graphqlError.message - }); - } - } catch (error) { - console.error('Analytics project detail error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved hentning af projekt detaljer', details: error.message }); - } -}); - -// Search endpoint - Search across cases via GraphQL -app.get('/api/analytics/search', async (req, res) => { - try { - const { q } = req.query; - - if (!q) { - return res.json({ success: true, data: [], count: 0 }); - } - - console.log(`🔍 Searching for: ${q}`); - - const SEARCH_QUERY = ` - query SearchCases { - cases(pagination: { cursor: null, limit: 100 }) { - items { - id - caseNumber - description - customer { - name - } - } - } - } - `; - - const searchData = await graphqlClient.request(SEARCH_QUERY); - const cases = searchData.cases?.items || []; - - // Filter results based on search term - const searchTerm = q.toLowerCase(); - const filtered = cases.filter(c => - c.caseNumber?.toLowerCase().includes(searchTerm) || - c.description?.toLowerCase().includes(searchTerm) || - c.customer?.name?.toLowerCase().includes(searchTerm) - ); - - res.json({ - success: true, - data: filtered, - count: filtered.length - }); - } catch (error) { - console.error('Analytics search error:', error.message); - res.status(500).json({ success: false, error: 'Fejl ved søgning', details: error.message }); - } -}); - -// Materials endpoint - Get material insights from local database -app.get('/api/analytics/materials', async (req, res) => { - try { - // Query materials insights from project_materials table - const materials = await databaseService.query(` - SELECT - material_name, - material_category as category, - unit, - COUNT(DISTINCT project_id) as projects_used_in, - SUM(quantity) as total_quantity_used, - AVG(quantity) as avg_quantity_per_project, - AVG(unit_price) as avg_unit_price, - MAX(created_at) as last_used - FROM project_materials - GROUP BY material_name, material_category, unit - HAVING projects_used_in > 0 - ORDER BY projects_used_in DESC, total_quantity_used DESC - LIMIT 100 - `); - - res.json({ - success: true, - data: materials, - count: materials.length - }); - } catch (error) { - console.error('Analytics materials error:', error.message); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af materiale indsigt', - details: error.message - }); - } -}); - -// Ordrestyring Business Report URL - Redirect to native reports -app.get('/api/analytics/ordrestyring-report', async (req, res) => { - try { - console.log('📊 Fetching Ordrestyring business report URL...'); - - const REPORT_QUERY = ` - query GetBusinessReport { - businessReportUrl - } - `; - - const reportData = await graphqlClient.request(REPORT_QUERY); - const reportUrl = reportData.businessReportUrl; - - if (!reportUrl) { - return res.status(404).json({ - success: false, - error: 'Business report URL ikke tilgængelig' - }); - } - - // Return URL for frontend to open in new tab - res.json({ - success: true, - reportUrl: reportUrl, - message: 'Åbn denne URL for at se Ordrestyring\'s native rapporter med alle detaljer' - }); - } catch (error) { - console.error('Ordrestyring report error:', error.message); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af Ordrestyring rapport', - details: error.message - }); - } -}); +// Analytics endpoints moved to backend/src/routes/analytics.js // =================================== // CUSTOMER SEARCH - Ordrestyring Database @@ -1161,11 +444,11 @@ app.get('/api/customers/search', async (req, res) => { const ordrestyringDb = await mysql.createConnection({ host: '127.0.0.1', user: 'tilbudgivern_service', - password: 'REDACTED_PASSWORD', + password: process.env.DB_PASSWORD, database: 'ordrestyring_local', charset: 'utf8mb4' }); - + try { // Search by customer number, name, email, or phone const [customers] = await ordrestyringDb.execute(` @@ -1258,11 +541,11 @@ app.get('/api/customers/:customerNumber', async (req, res) => { const ordrestyringDb = await mysql.createConnection({ host: '127.0.0.1', user: 'tilbudgivern_service', - password: 'REDACTED_PASSWORD', + password: process.env.DB_PASSWORD, database: 'ordrestyring_local', charset: 'utf8mb4' }); - + try { const [customers] = await ordrestyringDb.execute(` SELECT @@ -1812,9 +1095,13 @@ app.get('/api/dashboard/economics', async (req, res) => { // ORDRESTYRING NOEGLETAL DASHBOARD ENDPOINT - Real-time offer metrics from Ordrestyring API app.get('/api/dashboard/noegletal', async (req, res) => { try { - const mysql = require('mysql2/promise'); - const ordrestyringService = require('./src/services/ordrestyringService'); - + 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 @@ -3554,8 +2841,7 @@ app.post('/api/pricing/import', csvUpload.single('file'), async (req, res) => { app.get('/api/prices/template', (req, res) => { try { - const PriceImportService = require('./src/services/priceImportService').PriceImportService; - const importService = new PriceImportService(); + const importService = new PriceImportServiceClass(); const template = importService.generateCSVTemplate(); @@ -3672,8 +2958,7 @@ app.post('/api/pricing/bygma-prisbog', csvUpload.single('file'), async (req, res }); // Initialize Bygma import service - const BygmaPrisbogImportService = require('./src/services/bygmaPrisbogImportService'); - const importService = new BygmaPrisbogImportService(); + const importService = new BygmaPrisbogImportServiceClass(); // Read file and process const fs = require('fs'); @@ -3880,8 +3165,7 @@ app.post('/api/pricing/bygma-test-import', async (req, res) => { }); } - const BygmaPrisbogImportService = require('./src/services/bygmaPrisbogImportService'); - const importService = new BygmaPrisbogImportService(); + const importService = new BygmaPrisbogImportServiceClass(); const result = await importService.importPrisbog(filePath, uploadedBy); @@ -4321,8 +3605,7 @@ app.get('/api/prices/suggestions', async (req, res) => { }); } - const PriceImportService = require('./src/services/priceImportService').PriceImportService; - const importService = new PriceImportService(databaseService); + const importService = new PriceImportServiceClass(databaseService); const suggestions = await importService.getSuggestedPrices(material, category); @@ -5688,10 +4971,29 @@ app.get('/api/tag-geometry-estimates', async (req, res) => { const { area, tagType, complexity, taghældning, adgangsforhold } = req.query; 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 LocalOrdrestyringAnalyzer = require('./local_ordrestyring_analyzer'); - const analyzer = new LocalOrdrestyringAnalyzer(); + 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`; @@ -5855,6 +5157,27 @@ app.get('/api/tag-geometry-estimates', async (req, res) => { // Customer Projects API Routes app.use('/api/customer-projects', require('./src/routes/customerProjects')); +// Analytics API Routes +app.use('/api/analytics', require('./src/routes/analytics')); + +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')); @@ -5868,10 +5191,10 @@ app.use('/api/calendar', require('./routes/calendar')); app.use('/api/ordrestyring/case', require('./routes/cases')); // Visual AI Test Reports API Routes -app.use('/api/visual-reports', require('./routes/visualTestReports')); +app.use('/api/visual-reports', optionalAdminApiKeyGuard, require('./routes/visualTestReports')); -// Admin Logs API Routes (PROTECTED - should add auth middleware in production) -app.use('/api/admin/logs', require('./routes/adminLogs')); +// 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')); @@ -7078,63 +6401,11 @@ app.get('/api/uploads/recent', async (req, res) => { } }); -app.post('/api/uploads', async (req, res) => { - try { - // Simple upload handler - in reality you'd want proper file handling - res.json({ - success: true, - filename: 'placeholder.pdf', - message: 'Upload funktionalitet ikke implementeret endnu' - }); - } catch (error) { - console.error('Error handling upload:', error); - res.status(500).json({ - success: false, - error: 'Upload fejlede' - }); - } -}); // ==================== // CUSTOMER PROJECTS API // ==================== -// Get all customer projects (from ordrestyring) -app.get('/api/customer-projects/projects', async (req, res) => { - try { - // Get only projects from customer_projects table (not Ordrestyring cases) - const query = ` - SELECT - id, - project_name, - customer_name, - customer_email, - customer_phone, - customer_address, - project_description, - project_status, - project_type_id, - created_at, - updated_at - FROM customer_projects - ORDER BY created_at DESC - `; - - const projects = await databaseService.query(query); - - res.json({ - success: true, - projects: projects || [] - }); - } catch (error) { - console.error('Error loading projects:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved indlæsning af projekter', - details: error.message - }); - } -}); // Get similar projects based on keywords app.get('/api/customer-projects/similar', async (req, res) => { @@ -7160,7 +6431,7 @@ app.get('/api/customer-projects/similar', async (req, res) => { created_at, project_status FROM customer_projects - WHERE project_status IN ('sent', 'accepted', 'quote_generated') + 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 ? @@ -7188,79 +6459,6 @@ app.get('/api/customer-projects/similar', async (req, res) => { } }); -// Get material categories (doesn't require project validation) -app.get('/api/customer-projects/material-categories', async (req, res) => { - try { - console.log('🏷️ Loading material categories'); - - // Get unique categories from material_prices table - const categoriesResult = await databaseService.query(` - SELECT DISTINCT category - FROM material_prices - WHERE category IS NOT NULL AND category != '' - ORDER BY category - `); - - const categories = categoriesResult.map(row => row.category); - - console.log(`📦 Found ${categories.length} material categories:`, categories); - - res.json({ - success: true, - categories - }); - } catch (error) { - console.error('Error getting material categories:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af material kategorier' - }); - } -}); - -// Create new customer project -app.post('/api/customer-projects/projects', async (req, res) => { - try { - const { customerName, customerEmail, customerPhone, projectName, projectDescription, projectAddress } = req.body; - - // Validate required fields - if (!customerName || !projectName) { - return res.status(400).json({ - success: false, - error: 'Kunde navn og projekt navn er påkrævet' - }); - } - - const result = await databaseService.query(` - INSERT INTO customer_projects ( - customer_name, customer_email, customer_phone, - project_name, project_description, customer_address, - project_status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, 'draft', NOW(), NOW()) - `, [customerName, customerEmail || null, customerPhone || null, projectName, projectDescription || null, projectAddress || null]); - - // Get the complete project object to return to frontend - const newProject = await databaseService.query( - 'SELECT * FROM customer_projects WHERE id = ?', - [result.insertId] - ); - - console.log('✅ New project created:', newProject[0]); - - res.json({ - success: true, - project: newProject[0], // Return complete project object - projectId: result.insertId, - message: 'Projekt oprettet succesfuldt' - }); - } catch (error) { - console.error('Error creating project:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved oprettelse af projekt' - }); - } -}); // Get project step-by-step breakdown for manual review app.get('/api/customer-projects/:projectId/breakdown', async (req, res) => { @@ -7390,144 +6588,6 @@ app.get('/api/customer-projects/:projectId/breakdown', async (req, res) => { } }); -// Get all quotes for a project with detailed view -app.get('/api/customer-projects/:projectId/quotes', async (req, res) => { - try { - const { projectId } = req.params; - - console.log(`🔍 Getting quotes for project ID: ${projectId}`); - - // Get quotes from both systems - legacy project_quotes and new generated_quotes - let allQuotes = []; - - // Get quotes from legacy project_quotes table - try { - const legacyQuotes = await databaseService.query(` - SELECT id, project_type, customer_name, total_incl_vat, material_cost, - labor_cost, created_at, notes, metadata, 'legacy' as source - FROM project_quotes - WHERE JSON_EXTRACT(metadata, '$.project_id') = ? - ORDER BY created_at DESC - `, [projectId]); - - console.log(`📋 Found ${legacyQuotes.length} legacy quotes`); - allQuotes = allQuotes.concat(legacyQuotes); - } catch (error) { - console.log('No legacy quotes found or error:', error.message); - } - - // Get quotes from new generated_quotes table - try { - const newQuotes = await databaseService.query(` - SELECT - gq.id, - gq.quote_text, - gq.quote_format, - gq.created_at, - gq.quote_status, - gq.ai_model, - gq.ai_tokens_used, - gq.ai_cost, - CASE WHEN gq.quote_status = 'accepted' THEN 1 ELSE 0 END as accepted, - CASE WHEN gq.quote_status = 'rejected' THEN 1 ELSE 0 END as rejected, - CASE WHEN gq.quote_status = 'accepted' THEN gq.created_at ELSE NULL END as accepted_at, - CASE WHEN gq.quote_status = 'rejected' THEN gq.created_at ELSE NULL END as rejected_at, - cp.project_name, - 'new' as source - FROM generated_quotes gq - JOIN customer_projects cp ON gq.project_id = cp.id - WHERE gq.project_id = ? - ORDER BY gq.created_at DESC - `, [projectId]); - - console.log(`🆕 Found ${newQuotes.length} new quotes`); - allQuotes = allQuotes.concat(newQuotes); - } catch (error) { - console.log('No new quotes found or error:', error.message); - } - - // Process all quotes - const quotesWithDetails = allQuotes.map(quote => { - if (quote.source === 'new') { - // New system quotes - extract pricing from quote_text - let total_incl_vat = 0; - let labor_cost = 0; - let material_cost = 0; - - if (quote.quote_text) { - // Extract total price - const totalMatch = quote.quote_text.match(/SAMLET PRIS:\s*([0-9.,]+)/); - if (totalMatch) { - total_incl_vat = parseFloat(totalMatch[1].replace(/[.,]/g, '')); - } - - // Extract labor cost - const laborMatch = quote.quote_text.match(/Arbejdsløn:\s*([0-9.,]+)/); - if (laborMatch) { - labor_cost = parseFloat(laborMatch[1].replace(/[.,]/g, '')); - } - - // Extract material cost - const materialMatch = quote.quote_text.match(/Materialer:\s*([0-9.,]+)/); - if (materialMatch) { - material_cost = parseFloat(materialMatch[1].replace(/[.,]/g, '')); - } - } - - return { - ...quote, - total_incl_vat, - labor_cost, - material_cost, - labor_hours: labor_cost > 0 ? Math.round(labor_cost / 580) : 0, - materials_used: [], - calculation_method: quote.ai_model === 'static' ? 'Statisk template' : `AI (${quote.ai_model})`, - delivered_to_customer: true, // New quotes are considered delivered - customer_response: quote.accepted ? 'Accepteret' : quote.rejected ? 'Afslået' : quote.quote_status === 'sent' ? 'Sendt til kunde' : quote.quote_status === 'draft' ? 'Kladde' : 'Afventer svar' - }; - } else { - // Legacy system - parse metadata - 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 || 'Legacy system', - delivered_to_customer: metadata.delivered_to_customer || false, - customer_response: metadata.customer_response || 'Afventer svar', - accepted: false, // Legacy quotes don't have accept/reject tracking - rejected: false, - accepted_at: null, - rejected_at: null - }; - } - }); - - // Sort by created_at descending - quotesWithDetails.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - res.json({ - success: true, - data: quotesWithDetails, // Change from 'quotes' to 'data' to match API expectation - project_id: projectId, - count: quotesWithDetails.length - }); - - } catch (error) { - console.error('Error getting project quotes:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af projekt tilbud' - }); - } -}); // Get project details app.get('/api/customer-projects/:projectId', async (req, res) => { @@ -7572,16 +6632,30 @@ app.get('/api/customer-projects/:projectId', async (req, res) => { [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); @@ -7592,100 +6666,111 @@ app.get('/api/customer-projects/:projectId', async (req, res) => { } }); -// Update project +// Update customer project details/status/package selection app.put('/api/customer-projects/:projectId', async (req, res) => { try { - const { projectId } = req.params; - const { - projectName, - customerName, - customerEmail, - customerPhone, - projectAddress, - projectDescription, - project_status, - selected_package_id, - selected_package_name - } = req.body; + const { projectId: rawProjectId } = req.params; + const projectId = parseInt(rawProjectId, 10); - console.log(`[PROJECT UPDATE] ProjectId: ${projectId}, Body:`, req.body); - - // Validate projectId - if (!projectId || isNaN(projectId)) { + if (isNaN(projectId) || projectId <= 0) { return res.status(400).json({ success: false, error: 'Ugyldigt projekt ID' }); } - // Handle different types of updates - let query, params; - - if (selected_package_id !== undefined || selected_package_name !== undefined) { - // Updating package selection - console.log(`[PACKAGE UPDATE] Updating project ${projectId} package to: ${selected_package_name}`); + const updates = req.body || {}; + const setClauses = []; + const values = []; + + if (updates.selected_package_id !== undefined || updates.selected_package_name !== undefined) { const packageData = { - id: selected_package_id, - name: selected_package_name, + id: updates.selected_package_id ?? null, + name: updates.selected_package_name ?? null, updated_at: new Date().toISOString() }; - query = `UPDATE customer_projects SET selected_packages = ?, updated_at = NOW() WHERE id = ?`; - params = [JSON.stringify(packageData), projectId]; - } else if (project_status && !projectName) { - // Only updating status - console.log(`[STATUS UPDATE] Updating project ${projectId} status to: ${project_status}`); - query = `UPDATE customer_projects SET project_status = ?, updated_at = NOW() WHERE id = ?`; - params = [project_status, projectId]; - } else if (projectName) { - // Full project update - console.log(`[FULL UPDATE] Updating project ${projectId} with all fields`); - query = `UPDATE customer_projects - SET project_name = ?, customer_name = ?, customer_email = ?, - customer_phone = ?, customer_address = ?, project_description = ?, - updated_at = NOW() - WHERE id = ?`; - params = [projectName, customerName, customerEmail, customerPhone, projectAddress, projectDescription, projectId]; - } else { + 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 - skal have enten project_status, package selection eller projektdetaljer' + error: 'Ingen data at opdatere' }); } - console.log(`[SQL QUERY] ${query}`); - console.log(`[SQL PARAMS]`, params); + values.push(projectId); - const result = await databaseService.query(query, params); - console.log(`[UPDATE RESULT]`, result); + 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 med ID ${projectId} ikke fundet eller ingen ændringer foretaget` + error: 'Projekt ikke fundet' }); } - // Get updated project const [updatedProject] = await databaseService.query( 'SELECT * FROM customer_projects WHERE id = ?', [projectId] ); - console.log(`[UPDATED PROJECT]`, updatedProject); - res.json({ success: true, - project: updatedProject + project: updatedProject, + message: 'Projekt opdateret succesfuldt' }); } catch (error) { - console.error('[PROJECT UPDATE ERROR]', error); + console.error('Error updating project:', error); res.status(500).json({ success: false, - error: `Fejl ved opdatering af projekt: ${error.message}` + error: 'Fejl ved opdatering af projekt' }); } }); + // Delete project app.delete('/api/customer-projects/:projectId', async (req, res) => { try { @@ -7833,114 +6918,6 @@ app.get('/api/customer-projects/:projectId/materials', async (req, res) => { } }); -// ============================================================================ -// UPDATE CUSTOMER PROJECT -// Handles updating project fields including selected_package_id/name -// ============================================================================ -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: ${rawProjectId}` - }); - } - - const updates = req.body; - - console.log('📝 Updating customer project:', projectId, 'Fields:', Object.keys(updates)); - - // Whitelist of allowed fields (security) - const allowedFields = [ - 'project_name', - 'customer_name', - 'customer_email', - 'customer_phone', - 'customer_address', - 'project_description', - 'project_status', - 'selected_packages' - ]; - - // Build dynamic SQL UPDATE statement - const setClauses = []; - const values = []; - - // Handle package selection separately - if (updates.selected_package_id || updates.selected_package_name) { - const packageData = { - id: updates.selected_package_id, - name: updates.selected_package_name, - updated_at: new Date().toISOString() - }; - setClauses.push('selected_packages = ?'); - values.push(JSON.stringify(packageData)); - } - - for (const [key, value] of Object.entries(updates)) { - // Skip if it's the package fields (already handled) - if (key === 'selected_package_id' || key === 'selected_package_name') { - continue; - } - - // Snake_case conversion if needed - const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - - if (allowedFields.includes(dbKey)) { - setClauses.push(`${dbKey} = ?`); - values.push(value); - } else if (allowedFields.includes(key)) { - setClauses.push(`${key} = ?`); - values.push(value); - } else { - console.warn(`⚠️ Ignored non-whitelisted field: ${key}`); - } - } - - if (setClauses.length === 0) { - return res.status(400).json({ - success: false, - error: 'Ingen gyldige felter at opdatere' - }); - } - - // Add projectId to values array (for WHERE clause) - values.push(projectId); - - const sql = `UPDATE customer_projects SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = ?`; - - console.log('🗄️ Executing SQL:', sql); - console.log('📊 Values:', values); - - const result = await databaseService.query(sql, values); - - if (result.affectedRows === 0) { - return res.status(404).json({ - success: false, - error: 'Projekt ikke fundet' - }); - } - - console.log('✅ Project updated successfully'); - - res.json({ - success: true, - message: 'Projekt opdateret succesfuldt', - affectedRows: result.affectedRows - }); - - } catch (error) { - console.error('❌ Error updating customer project:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved opdatering af projekt', - details: error.message - }); - } -}); // Get selected packages for project app.get('/api/customer-projects/:projectId/packages', async (req, res) => { @@ -7983,8 +6960,7 @@ app.get('/api/customer-projects/:projectId/packages', async (req, res) => { // Enrich selected packages with full data from PackageService const enrichedPackages = []; - const PackageService = require('./src/services/packageService'); - const packageService = new PackageService(databaseService); + const packageService = new PackageServiceClass(databaseService); for (const savedPkg of selectedPackageIds) { let fullPackageData = null; @@ -8611,6 +7587,11 @@ app.post('/api/customer-projects/:projectId/geometry', async (req, res) => { }); console.log('✅ Broadcast complete'); + await databaseService.query( + 'UPDATE customer_projects SET project_status = ?, updated_at = NOW() WHERE id = ?', + ['geometry_complete', projectId] + ); + console.log('📤 Sending response...'); res.json({ success: true, @@ -9329,10 +8310,19 @@ app.get('/api/customer-projects/:projectId/materials/suggestions', async (req, r 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 LocalOrdrestyringAnalyzer = require('./local_ordrestyring_analyzer'); - const analyzer = new LocalOrdrestyringAnalyzer(); + const analyzer = new LocalOrdrestyringAnalyzerClass(); try { await analyzer.connect(); @@ -9438,44 +8428,7 @@ app.get('/api/customer-projects/:projectId/historical-data', async (req, res) => } }); -// Get detailed work breakdown for specific ordrestyring case -// Work breakdown endpoint - MIGRATED TO GraphQL (backend/routes/cases.js) -// Old local database version kept for reference -/* -app.get('/api/ordrestyring/case/:caseNumber/work-breakdown', async (req, res) => { - const caseNumber = req.params.caseNumber; - - try { - const LocalOrdrestyringAnalyzer = require('./local_ordrestyring_analyzer'); - const analyzer = new LocalOrdrestyringAnalyzer(); - - // Connect to database - await analyzer.connect(); - - // Get detailed breakdown including work hours and materials for this case - const breakdown = await analyzer.getCaseWorkBreakdown(caseNumber); - - // Disconnect - await analyzer.disconnect(); - - console.log(`📊 Found ${breakdown.work_breakdown.length} work categories and ${Object.keys(breakdown.materials_used).length} materials for case ${caseNumber}`); - - res.json({ - success: true, - case_number: caseNumber, - work_breakdown: breakdown.work_breakdown, - materials_used: breakdown.materials_used - }); - - } catch (error) { - console.error('❌ Error getting work breakdown for case:', error); - res.status(500).json({ - success: false, - error: 'Failed to get work breakdown from ordrestyring database' - }); - } -}); -*/ +// 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) => { @@ -9523,8 +8476,7 @@ app.post('/api/customer-projects/:projectId/quote', async (req, res) => { }); // Use AI quote generation with proper data - const ProjectQuoteGenerationService = require('./src/services/projectQuoteGenerationService'); - const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService); + const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, openaiService); result = await projectQuoteService.generateProfessionalQuote(projectId, calculation.id, { quoteStyle: 'professional', @@ -9541,8 +8493,7 @@ app.post('/api/customer-projects/:projectId/quote', async (req, res) => { }); // Use static quote generation (fallback if no AI available) - const ProjectQuoteGenerationService = require('./src/services/projectQuoteGenerationService'); - const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService); + const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, openaiService); result = await projectQuoteService.generateStaticQuote(projectId, calculation.id); } @@ -9550,8 +8501,7 @@ app.post('/api/customer-projects/:projectId/quote', async (req, res) => { 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 ProjectQuoteGenerationService = require('./src/services/projectQuoteGenerationService'); - const projectQuoteService = new ProjectQuoteGenerationService(databaseService, null); // No AI service + const projectQuoteService = new ProjectQuoteGenerationServiceClass(databaseService, null); // No AI service result = await projectQuoteService.generateStaticQuote(projectId, calculation.id); } @@ -9642,8 +8592,7 @@ app.get('/api/work-hour-statistics/:roofType', async (req, res) => { const { roofType } = req.params; const { areaMin, areaMax } = req.query; - const RoofGeometryService = require('./src/services/roofGeometryService'); - const roofService = new RoofGeometryService(databaseService); + const roofService = new RoofGeometryServiceClass(databaseService); const areaRange = areaMin && areaMax ? [parseFloat(areaMin), parseFloat(areaMax)] : undefined; const statistics = await roofService.getWorkHourStatistics(roofType, areaRange); @@ -9666,8 +8615,7 @@ app.post('/api/estimate-work-hours', async (req, res) => { try { const projectData = req.body; - const RoofGeometryService = require('./src/services/roofGeometryService'); - const roofService = new RoofGeometryService(databaseService); + const roofService = new RoofGeometryServiceClass(databaseService); const estimatedHours = await roofService.estimateWorkHoursWithAI(projectData); @@ -9885,8 +8833,7 @@ app.get('/api/quotes/:quoteId/pdf', async (req, res) => { console.log('📦 PDF: Found selected packages:', selectedPackageIds.length); // Enrich selected packages with full data from PackageService - const PackageService = require('./src/services/packageService'); - const packageService = new PackageService(databaseService); + const packageService = new PackageServiceClass(databaseService); for (const savedPkg of selectedPackageIds) { let fullPackageData = null; @@ -10570,7 +9517,7 @@ app.get('/api/customer-projects/:projectId/quotes', async (req, res) => { // - backend/routes/calendar.js (hours/calendar) // Test Ordrestyring API connection (DEPRECATED - for testing only) -app.get('/api/ordrestyring/test', async (req, res) => { +app.get('/api/ordrestyring/test', optionalAdminApiKeyGuard, async (req, res) => { try { res.json({ success: true, @@ -10590,7 +9537,7 @@ app.get('/api/ordrestyring/test', async (req, res) => { }); // Test Ordrestyring GraphQL API connection (DEPRECATED - for testing only) -app.get('/api/ordrestyring/test-graphql', async (req, res) => { +app.get('/api/ordrestyring/test-graphql', optionalAdminApiKeyGuard, async (req, res) => { try { res.json({ success: true, @@ -11502,8 +10449,7 @@ app.post('/api/vendor/bygma/import', async (req, res) => { }); } - const BygmaImportService = require('./src/services/bygmaImportService'); - const bygmaImporter = new BygmaImportService(databaseService); + const bygmaImporter = new BygmaImportServiceClass(databaseService); const result = await bygmaImporter.importESGData(filePath, fileName); @@ -12007,7 +10953,6 @@ app.get('/api/planning/employees', async (req, res) => { const threeMonthsAgo = Math.floor(new Date(Date.now() - (90 * 24 * 60 * 60 * 1000)).getTime() / 1000); const now = Math.floor(Date.now() / 1000); - const graphqlClient = require('./src/services/graphqlClient'); const result = await graphqlClient.request(` query GetActiveEmployees($between: HourBetweenInput!, $pagination: Pagination) { hours(between: $between pagination: $pagination) { @@ -12075,25 +11020,7 @@ app.get('/api/planning/employees', async (req, res) => { }); // ===================== ORDRESTYRING CALENDAR API ===================== -// DEPRECATED - Using GraphQL route at backend/routes/calendar.js -// All calendar endpoints now handled by backend/routes/calendar.js - -/* -// Get real calendar data from Ordrestyring -app.get('/api/calendar', async (req, res) => { ... }); - -// Get calendar statistics (must be before :id route) -app.get('/api/calendar/stats', async (req, res) => { ... }); - -// Force calendar sync (must be before :id route) -app.post('/api/calendar/sync', async (req, res) => { ... }); - -// Get calendar entry by ID -app.get('/api/calendar/:id', async (req, res) => { ... }); - -// Update calendar entry -app.put('/api/calendar/:id', async (req, res) => { ... }); -*/ +// Calendar endpoints are mounted from `backend/routes/calendar.js`. // Create new employee allocation app.post('/api/planning/allocations', async (req, res) => { @@ -12548,8 +11475,6 @@ app.get('/api/planning/calendar', async (req, res) => { } `; - const graphqlClient = require('./src/services/graphqlClient'); - 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, { @@ -12718,122 +11643,8 @@ app.get('/api/planning/availability', async (req, res) => { } }); -// Get historical data for better quotes -app.get('/api/analytics/historical-data', async (req, res) => { - try { - const { customer_number, project_type, period } = req.query; - - let periodFilter = ''; - if (period) { - const months = parseInt(period) || 12; - periodFilter = `AND c.created_at > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL ${months} MONTH))`; - } - - let customerFilter = ''; - if (customer_number) { - customerFilter = `AND c.customer_number = '${customer_number}'`; - } - - const [historicalData] = await db.execute(` - SELECT - AVG(cm.total_sales) as avg_material_cost, - AVG(h.duration_hours) as avg_hours_per_case, - AVG(h.hourly_cost) as avg_hourly_rate, - COUNT(DISTINCT c.case_number) as total_cases, - SUM(cm.total_sales) as total_material_value, - SUM(h.duration_hours * h.hourly_cost) as total_labor_cost - FROM ordrestyring_cases c - LEFT JOIN ordrestyring_case_materials cm ON c.case_number = cm.case_number - LEFT JOIN ordrestyring_hours h ON c.case_number = h.case_number - WHERE 1=1 ${customerFilter} ${periodFilter} - `); - - const analytics = await db.execute(` - SELECT * FROM tilbuds_analytics_cache - WHERE calculated_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) - ORDER BY calculated_at DESC - `); - - res.json({ - success: true, - historical_data: historicalData[0] || {}, - cached_analytics: analytics[0] || [], - period_months: period || 'all', - customer_number: customer_number || 'all' - }); - - } catch (error) { - console.error('Error fetching historical data:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af historiske data' - }); - } -}); - // ===== INTELLIGENT QUOTE CALCULATOR API ===== -// Initialize quote calculator -// const quoteCalculator = new HistoricalQuoteCalculator(); // Temporarily disabled - -// Generate intelligent quote based on historical data -app.post('/api/quotes/generate', async (req, res) => { - try { - logInfo('QUOTE_API', 'New quote request received', { body: req.body }); - - const { - customerNumber, - projectDescription, - estimatedSize = 'medium', - complexity = 3, - urgency = 'normal', - materials = [] - } = req.body; - - // Validate required fields - if (!customerNumber || !projectDescription) { - return res.status(400).json({ - success: false, - error: 'customerNumber og projectDescription er påkrævet' - }); - } - - // Generate intelligent quote - const quote = await quoteCalculator.generateIntelligentQuote({ - customerNumber, - projectDescription, - estimatedSize, - complexity: parseInt(complexity), - urgency, - materials - }); - - if (quote.success) { - logInfo('QUOTE_GENERATED', `Quote generated for customer ${customerNumber}`, { - totalQuote: quote.quote.totalQuote, - confidence: quote.confidence, - estimatedHours: quote.quote.estimatedHours - }); - - res.json({ - success: true, - quote: quote.quote, - confidence: quote.confidence, - recommendations: quote.recommendations, - historical_context: quote.historicalContext, - generated_at: new Date().toISOString() - }); - } else { - throw new Error(quote.error); - } - - } catch (error) { - logError('QUOTE_GENERATION', error, { requestBody: req.body }); - res.status(500).json({ - success: false, - error: 'Fejl ved generering af tilbud: ' + error.message - }); - } -}); +// 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) => { diff --git a/docs/COPILOT_MEMORY.md b/docs/COPILOT_MEMORY.md index c474335..9cb138a 100644 --- a/docs/COPILOT_MEMORY.md +++ b/docs/COPILOT_MEMORY.md @@ -6,11 +6,30 @@ Use this file to keep AI assistants (Copilot, Claude, Cursor) aligned on project **Tilbudgivern** is an AI-powered quote calculator for Danish carpenters, focused on roofing work. It uses OpenAI GPT-4 to generate professional quotes with auto-calculated materials and labor. +## Mission Memory + +This is the core mission and should guide product, UX, AI, and data decisions: + +- Tilbudgivern must make it possible for a carpenter to stand with the customer, note a few roof measurements and short project notes, and get to a usable quote draft as fast as possible. +- The primary use case is mobile phone on site or laptop in the van, so speed, low friction, and strong defaults matter more than long forms. +- The system should reuse as much historical data as possible from both local Tilbudgivern projects and Ordrestyring history. +- Smart Packages are the operational backbone: they should be easy to choose, easy to edit, and contain realistic carpenter tasks, hours, materials, and pricing structure. +- AI should act as a carpenter-friendly writing and cleanup assistant: fix spelling and structure, turn rough notes into sharp customer-facing offer text, but never silently invent or change factual numbers. +- Every quote flow should create reusable historical learning signals, so the system gets better the more it is used. + +Short version: + +> Fast field quote creation for carpenters, powered by reusable history, editable Smart Packages, and AI-assisted customer text. + ## Active Focus -- [ ] Smart Packages testing and refinement -- [ ] UI/UX improvements for quote creation flow -- [ ] Ordrestyring API GraphQL migration +- [ ] 2-minute quick quote flow for field use +- [ ] Reuse historical data from local projects + Ordrestyring in Smart Package selection +- [ ] Keep Smart Packages editable but prefilled from history +- [ ] Generate structured AI quote drafts from project/package/material/labor data +- [ ] Capture learning data from each completed quote flow +- [ ] Persist project workflow status in DB: `geometry_complete -> smart_package_complete -> review_pending -> ready_for_ordrestyring -> sent_to_ordrestyring` +- [ ] Show latest material pricelist freshness from persisted import logs/API in carpenter flow ## Tech Stack Quick Reference @@ -48,6 +67,7 @@ Pre-configured roofing packages with auto-calculation: - [CLAUDE.md](CLAUDE.md) - Claude Code instructions - [docs/README.md](docs/README.md) - Documentation index - [docs/deployment/PM2_GUIDE.md](docs/deployment/PM2_GUIDE.md) - PM2 management +- [docs/MISSION.md](MISSION.md) - Product mission and North Star ### Features - [docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md](docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md) - All 15 packages @@ -102,6 +122,15 @@ Required in `backend/.env`: Optional: - `ORDRESTYRING_API_TOKEN`, `OPENAI_ADMIN_KEY` +Business-owned OpenAI setup: +- All OpenAI usage must use the shared business account configuration, not ad hoc personal/project credentials. +- Prefer the centralized backend config helpers over direct client initialization. + +Workflow state memory: +- Final Review must not force sending. A carpenter must be able to leave a quote in `ready_for_ordrestyring` and return later. +- Frontend status displays should prefer persisted DB/API state over hardcoded assumptions. +- Material price freshness should come from persisted import logs such as Bygma/Stark import history, not from frontend-only state. + ## How to Use with AI Assistants **VS Code Copilot:** @@ -120,4 +149,4 @@ See .cursorrules and COPILOT_MEMORY.md for project context. ``` --- -*Last updated: January 2026* +*Last updated: 2026-03-29* diff --git a/docs/MISSION.md b/docs/MISSION.md new file mode 100644 index 0000000..d4c85c2 --- /dev/null +++ b/docs/MISSION.md @@ -0,0 +1,99 @@ +# Tilbudgivern Mission + +Last updated: 2026-03-29 + +## North Star + +Tilbudgivern exists to make quote creation so fast and reliable that a carpenter can stand with the customer, measure the roof, note a few project facts, and leave with a strong quote draft the same day. + +The product should work equally well on: +- mobile phone on site +- laptop in the van +- desktop in the office + +## Core Mission + +1. Minimize friction in the field. +The carpenter should only need a few inputs to get started: +- customer/address +- project type +- roof type/material +- length/width/pitch or rough area +- short customer note + +2. Reuse as much real data as possible. +Tilbudsgiveren should prefer real historical evidence over empty forms: +- local Tilbudgivern project history +- synced Ordrestyring history +- previously used Smart Packages +- previously accepted/sent offers + +3. Make Smart Packages the operational backbone. +Smart Packages should be: +- quick to choose +- easy to edit +- grounded in real carpenter work +- structured with materials, tasks, hours, and pricing + +4. Let AI help with language, not fake facts. +AI should: +- fix spelling and messy notes +- structure rough carpenter input +- produce sharp, customer-friendly offer text +- explain the work clearly + +AI must not: +- invent measurements +- invent prices +- silently change quantities, rates, or totals +- fabricate work not present in project/package data + +5. Make the system improve through usage. +Every completed quote flow should create reusable learning signals: +- what package was suggested +- what package was chosen +- what materials/tasks were changed +- what text was accepted +- which historical cases were used + +The system should get better over time as more quotes are created. + +## Product Principles + +- Field-first beats backoffice-first. +- Prefill beats blank state. +- Editable beats locked automation. +- Real history beats generic AI. +- Short path to first useful draft beats perfect initial completeness. +- Structured project data beats free-text prompts. + +## What “Good” Looks Like + +A carpenter can: +- create a project in under 2 minutes +- get a relevant Smart Package recommendation automatically +- receive suggested materials and hours from similar jobs +- generate a customer-ready quote draft from structured data +- correct mistakes quickly before sending +- leave the quote in a clear persisted status before sending to Ordrestyring + +## Implementation Direction + +When choosing features, architecture, or UX, prefer work that moves the product toward: + +- quick quote flow +- strong historical matching +- local sync and reuse of Ordrestyring data +- structured quote generation +- mobile-friendly editing and confirmation +- persisted workflow states that match the real carpenter journey +- visible material pricelist freshness from synced supplier/import data +- measurable learning from accepted/edited quotes + +## Non-Goals + +Avoid optimizing primarily for: +- long admin workflows before first quote value +- AI-generated content without structured project grounding +- rigid package systems that are hard for carpenters to override +- flows that assume office-only usage diff --git a/frontend/src/components/FinalReview.js b/frontend/src/components/FinalReview.js index a0c9a51..f45fd4d 100644 --- a/frontend/src/components/FinalReview.js +++ b/frontend/src/components/FinalReview.js @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useNotification } from '../hooks/useNotification'; import './FinalReview.css'; +import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '../utils/projectStatus'; const FinalReview = ({ apiBaseUrl, @@ -19,6 +20,9 @@ const FinalReview = ({ // Editable pricing state const [editableMaterials, setEditableMaterials] = useState(packageData?.materials || []); const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []); + const [editableProjectDescription, setEditableProjectDescription] = useState( + packageData?.recommendedDescription || project?.project_description || project?.description || '' + ); const [materialProfitMargin, setMaterialProfitMargin] = useState(20); // 20% på materialer const [laborProfitMargin, setLaborProfitMargin] = useState(2); // 2% på timer @@ -32,6 +36,103 @@ const FinalReview = ({ // PDF state const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [pdfUrl, setPdfUrl] = useState(null); + const [orderSuggestions, setOrderSuggestions] = useState(null); + const [isLoadingOrderSuggestions, setIsLoadingOrderSuggestions] = useState(false); + const [orderSuggestionError, setOrderSuggestionError] = useState(''); + const [projectStatus, setProjectStatus] = useState(project?.project_status || PROJECT_STATUS.DRAFT); + const [materialPriceStatus, setMaterialPriceStatus] = useState(null); + const [isSavingReadyStatus, setIsSavingReadyStatus] = useState(false); + + const getErrorMessage = React.useCallback((errorLike, fallback = 'Ukendt fejl') => { + if (!errorLike) { + return fallback; + } + + if (typeof errorLike === 'string') { + return errorLike; + } + + if (typeof errorLike.message === 'string') { + return errorLike.message; + } + + if (typeof errorLike.error === 'string') { + return errorLike.error; + } + + try { + return JSON.stringify(errorLike); + } catch (error) { + return fallback; + } + }, []); + + const buildLocalStaticQuote = React.useCallback((quoteData) => { + const projectName = quoteData?.project?.project_name || quoteData?.project?.name || 'Tilbud'; + const customerName = quoteData?.project?.customer_name || 'Kunde'; + const address = quoteData?.project?.customer_address || 'Opmålt hos kunde'; + const roofType = quoteData?.geometry?.roof_type || 'Tagarbejde'; + const roofArea = quoteData?.geometry?.total_area || quoteData?.geometry?.roofArea || 0; + const packageName = quoteData?.package?.name || ''; + const materials = Array.isArray(quoteData?.materials) ? quoteData.materials : []; + const labor = Array.isArray(quoteData?.labor) ? quoteData.labor : []; + const totals = quoteData?.totals || {}; + + const materialLines = materials.slice(0, 6).map(material => + ` • ${material.name || material.material_name}: ${material.quantity} ${material.unit}` + ).join('\n'); + + const laborLines = labor.map(task => + ` • ${task.name || task.task_name}${task.description ? ` - ${task.description}` : ''} (${(parseFloat(task.totalHours ?? task.estimated_hours) || 0).toFixed(1)} timer)` + ).join('\n'); + + return `${String(projectName).toUpperCase()}\n\nProjekt\n • Kunde: ${customerName}\n • Adresse: ${address}\n • Tagtype: ${roofType}\n • Tagareal: ${roofArea} m²${packageName ? `\n • Valgt løsning: ${packageName}` : ''}\n\nArbejdet omfatter\n${laborLines || ' • Arbejdsopgaver aftales efter endelig gennemgang'}\n\nMaterialer\n${materialLines || ' • Materialer specificeres efter endelig opmåling'}\n\nPris\n • Materialer: ${formatCurrency(totals.materials || 0)} ekskl. moms\n • Arbejde: ${formatCurrency(totals.labor || 0)} ekskl. moms\n • Subtotal: ${formatCurrency(totals.subtotal || 0)} ekskl. moms\n • Moms (25%): ${formatCurrency(totals.tax || 0)}\n • Samlet pris: ${formatCurrency(totals.total || 0)} inkl. moms`; + }, []); + + const applySuggestedSolution = React.useCallback(async () => { + if (!orderSuggestions) { + return; + } + + const suggestedDescription = + orderSuggestions.prefill?.suggestedDescription || + orderSuggestions.recommendation?.suggestedDescription || + ''; + const suggestedMaterials = orderSuggestions.prefill?.materials || orderSuggestions.materialsDraft || []; + const suggestedLaborTasks = orderSuggestions.prefill?.laborTasks || orderSuggestions.laborDraft || []; + + if (suggestedDescription) { + setEditableProjectDescription(suggestedDescription); + } + + if (suggestedMaterials.length > 0) { + setEditableMaterials(suggestedMaterials); + } + + if (suggestedLaborTasks.length > 0) { + setEditableLaborTasks(suggestedLaborTasks); + } + + try { + await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/apply-suggestion`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + recommendedPackage: orderSuggestions.recommendation?.recommendedPackage || null, + suggestionSnapshot: orderSuggestions, + projectDescription: suggestedDescription, + inputNormalizationLog: { + source: 'final_review_apply_suggestion', + appliedAt: new Date().toISOString() + } + }) + }); + } catch (error) { + console.warn('Could not persist suggestion snapshot:', error); + } + }, [apiBaseUrl, orderSuggestions, project?.id]); // Scroll to top when component mounts React.useEffect(() => { @@ -65,14 +166,14 @@ const FinalReview = ({ }); // Normaliser geometri data til at håndtere begge navnekonventioner - const normalizedGeometry = geometryData ? { + const normalizedGeometry = React.useMemo(() => (geometryData ? { width: geometryData.width || geometryData.width_main || 0, length: geometryData.length || geometryData.length_main || 0, wallHeight: geometryData.wallHeight || geometryData.wall_height || 0, roofPitch: geometryData.roofPitch || geometryData.roof_pitch || 0, roofType: geometryData.roofType || geometryData.roof_type || 'unknown', totalArea: geometryData.totalArea || geometryData.total_area || 0 - } : null; + } : null), [geometryData]); console.log('📊 Normalized geometry:', normalizedGeometry); @@ -84,7 +185,98 @@ const FinalReview = ({ if (packageData?.laborTasks) { setEditableLaborTasks(packageData.laborTasks); } - }, [packageData]); + setEditableProjectDescription( + packageData?.recommendedDescription || project?.project_description || project?.description || '' + ); + }, [packageData, project]); + + React.useEffect(() => { + setProjectStatus(project?.project_status || PROJECT_STATUS.DRAFT); + }, [project?.project_status]); + + const persistProjectStatus = React.useCallback(async (status) => { + if (!project?.id) { + return false; + } + + const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/status`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ status }) + }); + + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || 'Kunne ikke opdatere projektstatus'); + } + + setProjectStatus(status); + return true; + }, [apiBaseUrl, project?.id]); + + React.useEffect(() => { + const loadMaterialPriceStatus = async () => { + try { + const response = await fetch(`${apiBaseUrl}/api/customer-projects/material-price-status`); + const data = await response.json(); + + if (data.success) { + setMaterialPriceStatus(data); + } + } catch (error) { + console.warn('Could not load material price status:', error); + } + }; + + loadMaterialPriceStatus(); + }, [apiBaseUrl]); + + React.useEffect(() => { + if (!project?.id || isFinalizedProjectStatus(projectStatus) || projectStatus === PROJECT_STATUS.REVIEW_PENDING) { + return; + } + + if (!editableMaterials.length && !editableLaborTasks.length) { + return; + } + + persistProjectStatus(PROJECT_STATUS.REVIEW_PENDING).catch((error) => { + console.warn('Could not persist review status:', error); + }); + }, [editableLaborTasks.length, editableMaterials.length, persistProjectStatus, project?.id, projectStatus]); + + React.useEffect(() => { + const loadOrderSuggestions = async () => { + if (!project?.id) { + return; + } + + setIsLoadingOrderSuggestions(true); + setOrderSuggestionError(''); + + try { + const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/order-suggestions`); + const data = await response.json(); + + if (data.success) { + setOrderSuggestions(data); + } else { + setOrderSuggestions(null); + setOrderSuggestionError(data.error || 'Kunne ikke hente Ordrestyring forslag'); + } + } catch (error) { + console.error('Error loading order suggestions:', error); + setOrderSuggestions(null); + setOrderSuggestionError('Netværksfejl ved hentning af Ordrestyring forslag'); + } finally { + setIsLoadingOrderSuggestions(false); + } + }; + + loadOrderSuggestions(); + }, [apiBaseUrl, project?.id]); // Beregn totaler baseret på editable data const calculateMaterialTotal = () => { @@ -105,7 +297,8 @@ const FinalReview = ({ if (onBackWithData) { onBackWithData({ materials: editableMaterials, - laborTasks: editableLaborTasks + laborTasks: editableLaborTasks, + recommendedDescription: editableProjectDescription }); } else { // Fallback to regular onBack if no data handler @@ -123,6 +316,11 @@ const FinalReview = ({ const taxRate = 0.25; // 25% moms const taxAmount = subtotalWithProfit * taxRate; const grandTotal = subtotalWithProfit + taxAmount; + const projectStatusMeta = getProjectStatusMeta(projectStatus); + const primaryMaterialSource = materialPriceStatus?.primarySource || null; + const formattedPriceImportDate = primaryMaterialSource?.lastImportAt + ? new Date(primaryMaterialSource.lastImportAt).toLocaleString('da-DK') + : null; const handleSubmitToOrdering = async () => { setIsSubmitting(true); @@ -134,7 +332,11 @@ const FinalReview = ({ id: project.id, name: project.name, customer: project.customer_name, - description: project.description + customerNumber: project.customer_number, + customerEmail: project.customer_email, + customerPhone: project.customer_phone, + customerAddress: project.customer_address, + description: editableProjectDescription }, geometry: { width: normalizedGeometry.width, @@ -144,7 +346,7 @@ const FinalReview = ({ roofArea: calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch) }, package: { - materials: packageData.materials.map(material => ({ + materials: editableMaterials.map(material => ({ name: material.name, quantity: material.quantity, unit: material.unit, @@ -152,7 +354,7 @@ const FinalReview = ({ total: material.quantity * material.unitPrice, category: material.category })), - laborTasks: packageData.laborTasks.map(task => ({ + laborTasks: editableLaborTasks.map(task => ({ name: task.name, description: task.description, area: task.area, @@ -193,6 +395,11 @@ const FinalReview = ({ const result = await response.json(); if (result.success) { + try { + await persistProjectStatus(PROJECT_STATUS.SENT_TO_ORDRESTYRING); + } catch (statusError) { + console.warn('Could not persist sent status:', statusError); + } setSubmitResult({ success: true, offerNumber: result.offerNumber, // ✅ Now returns real offer number from GraphQL! @@ -222,6 +429,19 @@ const FinalReview = ({ } }; + const handleMarkReadyForOrdrestyring = async () => { + setIsSavingReadyStatus(true); + + try { + await persistProjectStatus(PROJECT_STATUS.READY_FOR_ORDRESTYRING); + notify.success('Projektet er markeret som klar til Ordrestyring'); + } catch (error) { + notify.error(getErrorMessage(error, 'Kunne ikke gemme status')); + } finally { + setIsSavingReadyStatus(false); + } + }; + const calculateRoofArea = (width, length, pitch) => { const pitchRad = (pitch * Math.PI) / 180; const roofSlope = width / (2 * Math.cos(pitchRad)); @@ -229,61 +449,64 @@ const FinalReview = ({ }; // Generer tilbudstekst (statisk eller AI) - const handleGenerateQuote = async (type = 'static') => { + const handleGenerateQuote = React.useCallback(async (type = 'static') => { setIsGeneratingQuote(true); + const quoteData = { + project: { + id: project.id, + name: project.project_name || project.name, + customer_name: project.customer_name, + customer_number: project.customer_number, + customer_address: project.customer_address, + description: editableProjectDescription + }, + geometry: normalizedGeometry, + package: { + ...packageData, + recommendedDescription: editableProjectDescription, + materials: editableMaterials, + laborTasks: editableLaborTasks + }, + // Include ALL detailed materials + materials: editableMaterials.map(m => ({ + material_name: m.name, + name: m.name, + quantity: parseFloat(m.quantity) || 0, + unit: m.unit, + unit_price: parseFloat(m.unitPrice) || 0, + unitPrice: parseFloat(m.unitPrice) || 0, + total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0), + category: m.category + })), + // Include ALL detailed labor items + labor: editableLaborTasks.map(item => ({ + task_name: item.name, + name: item.name, + description: item.description, + estimated_hours: parseFloat(item.totalHours) || 0, + totalHours: parseFloat(item.totalHours) || 0, + hourly_rate: parseFloat(item.rate) || 0, + rate: parseFloat(item.rate) || 0, + total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0) + })), + totals: { + materials: materialTotal, + labor: laborTotal, + subtotal: subtotal, + tax: taxAmount, + total: grandTotal, + totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0), + hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580 + } + }; try { - const quoteData = { - project: { - id: project.id, - name: project.project_name || project.name, - customer_name: project.customer_name, - description: project.description - }, - geometry: normalizedGeometry, - package: packageData, - // Include ALL detailed materials - materials: editableMaterials.map(m => ({ - material_name: m.name, - name: m.name, - quantity: parseFloat(m.quantity) || 0, - unit: m.unit, - unit_price: parseFloat(m.unitPrice) || 0, - unitPrice: parseFloat(m.unitPrice) || 0, - total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0), - category: m.category - })), - // Include ALL detailed labor items - labor: editableLaborTasks.map(item => ({ - task_name: item.name, - name: item.name, - description: item.description, - estimated_hours: parseFloat(item.totalHours) || 0, - totalHours: parseFloat(item.totalHours) || 0, - hourly_rate: parseFloat(item.rate) || 0, - rate: parseFloat(item.rate) || 0, - total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0) - })), - totals: { - materials: materialTotal, - labor: laborTotal, - subtotal: subtotal, - tax: taxAmount, - total: grandTotal, - totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0), - hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580 - } - }; - - const endpoint = type === 'ai' - ? '/api/quotes/generate-ai' - : '/api/quotes/generate-static'; - - const response = await fetch(`${apiBaseUrl}${endpoint}`, { + const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/generate-quote-draft`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - projectId: project.id, + mode: type, quoteData: quoteData, + suggestionData: orderSuggestions, customInstructions: aiInstructions }) }); @@ -296,44 +519,29 @@ const FinalReview = ({ const result = await response.json(); if (!response.ok) { - throw new Error(result.error || `Server fejl: ${response.status}`); + throw new Error(getErrorMessage(result, `Server fejl: ${response.status}`)); } - // Handle async polling pattern (AI jobs take longer than Nginx 30s timeout) - if (result.polling && result.jobId) { - const jobId = result.jobId; - let attempts = 0; - const maxAttempts = 80; // 80 × 3s = 4 minutes max - while (attempts < maxAttempts) { - await new Promise(r => setTimeout(r, 3000)); - attempts++; - const pollRes = await fetch(`${apiBaseUrl}/api/quotes/status/${jobId}`); - if (!pollRes.ok) continue; - const pollData = await pollRes.json(); - if (pollData.status === 'done') { - setQuoteText(pollData.quoteText); - setShowQuoteEditor(true); - break; - } else if (pollData.status === 'error') { - throw new Error(pollData.error || 'AI generering fejlede'); - } - } - if (attempts >= maxAttempts) { - throw new Error('AI generering tog for lang tid. Prøv igen.'); - } - } else if (result.success) { + if (result.success) { setQuoteText(result.quoteText); setShowQuoteEditor(true); } else { - notify.error('Fejl ved generering af tilbudstekst: ' + (result.error || 'Ukendt fejl')); + throw new Error(getErrorMessage(result, 'Ukendt fejl')); } } catch (error) { console.error('Error generating quote:', error); - notify.error('Fejl ved generering af tilbudstekst: ' + error.message); + if (type === 'static') { + const fallbackQuote = buildLocalStaticQuote(quoteData); + setQuoteText(fallbackQuote); + setShowQuoteEditor(true); + notify.warning(`Backend-fejl ved standard tekst. Viser lokal kladde i stedet: ${getErrorMessage(error)}`); + } else { + notify.error('Fejl ved generering af tilbudstekst: ' + getErrorMessage(error)); + } } finally { setIsGeneratingQuote(false); } - }; + }, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableLaborTasks, materialTotal, laborTotal, subtotal, taxAmount, grandTotal, orderSuggestions, aiInstructions, getErrorMessage, buildLocalStaticQuote, notify]); // Generer og download PDF const handleGeneratePDF = async () => { @@ -347,7 +555,7 @@ const FinalReview = ({ name: project.name, project_name: project.name, customer_name: project.customer_name, - description: project.description + description: editableProjectDescription }, geometry: { width: normalizedGeometry.width, @@ -504,7 +712,32 @@ const FinalReview = ({
Gennemgå alle detaljer før indsendelse til ordrestyring
+Gennemgå, finpuds og gem tilbuddet før du eventuelt sender det til Ordrestyring
+- Når du klikker "Send til Ordrestyring", bliver tilbuddet automatisk overført til ordrestyring systemet - hvor det kan spores og administreres. + Du kan gemme tilbuddet som klart til Ordrestyring uden at sende det endnu. Send først, når tilbudet er helt klar.
+ +- ℹ️ Alle beregninger er baseret på B7 Tag Udskiftning pakken med præcise mængder og tider + ℹ️ Alle beregninger er baseret på {selectedPackage.name} pakken med præcise mængder og tider
-
+ Skriv fx velux. Systemet finder de 5 nyeste relevante match i beskrivelse, tilbudstekst og materialer, vurderer dem og bygger et redigerbart udkast. +
++ {orderSuggestions.recommendation.summary} +
++ Historisk forslag eller egen tekst. Denne tekst følger smart-pakken som redigerbart udkast. +
+