From c7293c99ada279803f1379554c12ff2cdd631e80 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sun, 4 Oct 2026 20:23:09 +0200 Subject: [PATCH] [verified] fix(ordrestyring): enforce canonical release readiness --- .../ordrestyringConsumerCutover.test.js | 2 +- .../__tests__/ordrestyringPreflight.test.js | 74 +++ .../ordrestyringReleaseReadiness.test.js | 123 +++++ .../src/services/enhancedOrderDataService.js | 495 +----------------- .../historicalSmartPackageSearchService.js | 32 +- .../src/services/orderSuggestionService.js | 22 +- backend/src/services/ordrestyringPreflight.js | 38 ++ backend/src/services/ordrestyringReadModel.js | 8 +- backend/src/services/planningService.js | 28 +- backend/unified-server.js | 160 +----- 10 files changed, 331 insertions(+), 651 deletions(-) create mode 100644 backend/src/services/__tests__/ordrestyringPreflight.test.js create mode 100644 backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js create mode 100644 backend/src/services/ordrestyringPreflight.js diff --git a/backend/src/services/__tests__/ordrestyringConsumerCutover.test.js b/backend/src/services/__tests__/ordrestyringConsumerCutover.test.js index e3b1f26..395988d 100644 --- a/backend/src/services/__tests__/ordrestyringConsumerCutover.test.js +++ b/backend/src/services/__tests__/ordrestyringConsumerCutover.test.js @@ -122,7 +122,7 @@ test('planning orders join current identities once and expose eligible value sep test('canonical history ignores amplified legacy rows before limit and contributes each case once', async () => { const cases = [{ case_number: 'A', description: 'roof A', total_hours: 1 }, { case_number: 'B', description: 'roof B', total_hours: 2 }]; const execute = jest.fn(async (sql, params) => { - if (sql.includes('ordrestyring_cases_current')) return [cases.slice(0, params.at(-1))]; + if (sql.includes('ordrestyring_cases_current')) return [cases.slice(params.at(-1), params.at(-1) + params.at(-2))]; if (sql.includes('ordrestyring_material_semantics')) return [[]]; if (sql.includes('ordrestyring_local.hours')) return [[ { case_number: 'A', start_time: 3600, stop_time: 7200 }, diff --git a/backend/src/services/__tests__/ordrestyringPreflight.test.js b/backend/src/services/__tests__/ordrestyringPreflight.test.js new file mode 100644 index 0000000..8a6b25d --- /dev/null +++ b/backend/src/services/__tests__/ordrestyringPreflight.test.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); +const { assertOrdrestyringReadiness } = require('../ordrestyringPreflight'); +const tables = ['ordrestyring_cases_current', 'ordrestyring_debtors_current', 'ordrestyring_material_semantics', 'ordrestyring_projection_state']; +function fixture() { + const states = ['cases', 'debtors', 'materials'].map(projection_kind => ({ projection_kind, source_account: 'account', row_count: 2, sync_run_id: 'run', mirrored_at: '2026-10-04' })); + const counts = { actual_count: 2, account_count: 2 }; + const db = { execute: jest.fn(async sql => { + if (sql.includes('INFORMATION_SCHEMA')) return [tables.map(table_name => ({ table_name }))]; + if (sql.includes('FROM ordrestyring_local.ordrestyring_projection_state')) return [states]; + return [[counts]]; + }) }; + return { db, states, counts }; +} +test('ready canonical boot is read-only and checks all four tables', async () => { + const { db } = fixture(); + await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: 'account' })).resolves.toMatchObject({ ready: true }); + expect(db.execute.mock.calls.every(([sql]) => /^\s*SELECT/.test(sql))).toBe(true); + for (const table of tables) expect(JSON.stringify(db.execute.mock.calls)).toContain(table); +}); +test('missing source account and invalid modes refuse boot', async () => { + await expect(assertOrdrestyringReadiness(fixture().db, {})).rejects.toThrow(/SOURCE_ACCOUNT/); + await expect(assertOrdrestyringReadiness(fixture().db, { ORDRESTYRING_READ_MODE: 'typo' })).rejects.toThrow(/mode/i); +}); +test('explicit legacy rollback needs no migration but canonical domain overrides do', async () => { + const { db } = fixture(); + await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_READ_MODE: 'legacy' })).resolves.toMatchObject({ ready: true }); + expect(db.execute).not.toHaveBeenCalled(); + await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_READ_MODE: 'legacy', ORDRESTYRING_READ_MODE_PLANNING: 'canonical' })).rejects.toThrow(/SOURCE_ACCOUNT/); +}); +test.each(['missing table', 'missing state', 'wrong account', 'zero expected', 'wrong count', 'mixed accounts', 'unmirrored'])('%s refuses canonical boot', async problem => { + const { db, states, counts } = fixture(); + if (problem === 'missing table') db.execute.mockResolvedValueOnce([tables.slice(1).map(table_name => ({ table_name }))]); + if (problem === 'missing state') states.pop(); + if (problem === 'wrong account') states[0].source_account = 'other'; + if (problem === 'zero expected') states[0].row_count = 0; + if (problem === 'wrong count') counts.actual_count = 1; + if (problem === 'mixed accounts') counts.account_count = 1; + if (problem === 'unmirrored') states[0].sync_run_id = null; + await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: 'account' })).rejects.toThrow(/Ordrestyring/); +}); +test('actual server start awaits preflight before service initialization and listen', () => { + const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8'); + const start = source.slice(source.indexOf('const startServer = async')); + expect(start.indexOf('await assertOrdrestyringReadiness(')).toBeGreaterThan(-1); + expect(start.indexOf('await assertOrdrestyringReadiness(')).toBeLessThan(start.indexOf('await initializeBackend()')); + expect(start.indexOf('await initializeBackend()')).toBeLessThan(start.indexOf('server.listen(')); +}); + +test.each([false, true])('actual start function only initializes and listens after successful readiness: %s', async ready => { + const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8'); + const start = source.slice(source.indexOf('const startServer = async'), source.indexOf('startServer().catch')); + const { db } = fixture(); + const preflight = connection => assertOrdrestyringReadiness(connection, ready ? { ORDRESTYRING_SOURCE_ACCOUNT: 'account' } : {}); + const initializeBackend = jest.fn().mockResolvedValue(true); + const server = { listen: jest.fn() }; + const boot = new Function('assertOrdrestyringReadiness', 'db', 'initializeBackend', 'server', 'PORT', `${start}; return startServer;`)(preflight, db, initializeBackend, server, 4032); + if (ready) { + await boot(); + expect(initializeBackend).toHaveBeenCalledTimes(1); + expect(server.listen).toHaveBeenCalledTimes(1); + } else { + await expect(boot()).rejects.toThrow(/SOURCE_ACCOUNT/); + expect(initializeBackend).not.toHaveBeenCalled(); + expect(server.listen).not.toHaveBeenCalled(); + } +}); + +test.each(['private account name', 'a'.repeat(129)])('source account must satisfy the projection account contract', async account => { + const { db, states } = fixture(); + states.forEach(state => { state.source_account = account; }); + await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: account })).rejects.toThrow(/SOURCE_ACCOUNT/); + expect(db.execute).not.toHaveBeenCalled(); +}); diff --git a/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js b/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js new file mode 100644 index 0000000..a1d6096 --- /dev/null +++ b/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js @@ -0,0 +1,123 @@ +const fs = require('fs'); +const path = require('path'); +const Planning = require('../planningService'); +const Suggestions = require('../orderSuggestionService'); +const Enhanced = require('../enhancedOrderDataService'); +const { OrdrestyringReadModel, hoursRelation, planningHoursRelation } = require('../ordrestyringReadModel'); + +const allocation = { employee_id: 7, case_number: 'CURRENT', start_time: '2026-10-04T08:00:00Z' }; +test.each(['createAllocation', 'updateAllocation'])('%s validates canonical identity and rejects frozen-only cases', async method => { + const query = jest.fn(async sql => { + if (sql.includes('ordrestyring_cases_current')) return []; + if (sql.includes('INSERT') || sql.includes('UPDATE')) return { insertId: 1 }; + return [{ id: 7, case_number: 'CURRENT' }]; + }); + const service = new Planning({ query }); + await expect(method === 'createAllocation' ? service[method](allocation) : service[method](1, allocation)).rejects.toThrow('Case not found'); + expect(query.mock.calls.some(([sql]) => /INSERT|UPDATE/.test(sql))).toBe(false); +}); +test('canonical-only case can be allocated and explicit legacy rollback uses raw identity', async () => { + for (const mode of ['canonical', 'legacy']) { + const query = jest.fn(async sql => { + if (sql.includes('INSERT')) return { insertId: 1 }; + if (sql.includes('.cases WHERE')) return mode === 'legacy' ? [{ case_number: 'CURRENT' }] : []; + return [{ id: 7, case_number: 'CURRENT' }]; + }); + await expect(new Planning({ query }, { env: { ORDRESTYRING_READ_MODE: mode } }).createAllocation(allocation)).resolves.toMatchObject({ success: true }); + } +}); +test('planning keeps open allocations while benchmark hours remain positive-duration only', async () => { + expect(planningHoursRelation).toMatch(/stop_time = 0/); + expect(hoursRelation).not.toMatch(/stop_time = 0/); + const start = Math.floor(Date.now() / 1000); + const query = jest.fn(async sql => { + if (sql.includes('FROM ordrestyring_local.users')) return [{ id: 7 }]; + if (sql.includes('stop_time = 0')) return [{ id: 1, emp_id: 7, start_time: start, stop_time: 0 }]; + return []; + }); + const service = new Planning({ query }); + expect((await service.getEmployees())[0].upcoming_allocations).toHaveLength(1); + const weeks = Object.values(await service.getCalendarData()); + expect(weeks[0].employees[7].allocations[0]).toMatchObject({ duration_hours: 0, end_date: null }); +}); +test('canonical candidate SQL supports bounded deterministic windows', async () => { + const execute = jest.fn().mockResolvedValue([[]]); + await new OrdrestyringReadModel({ execute }).candidates({ limit: 12, offset: 24 }); + expect(execute.mock.calls[0][0]).toContain('LIMIT ? OFFSET ?'); + expect(execute.mock.calls[0][1].slice(-2)).toEqual([12, 24]); +}); +test('suggestions continue beyond excluded windows and retain unique positive-hour identities', async () => { + const service = new Suggestions({ pool: { execute: jest.fn() } }); + const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) => + Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: offset < 24 ? 'test roof' : 'roof', total_hours: 2 }))); + const rows = await service.getOrderCandidateCases({}, ['roof'], 12); + expect(rows.filter(row => !service.getHistoryCandidateExclusions({}, row).length)).toHaveLength(12); + expect(candidates.mock.calls.map(([args]) => args.offset)).toEqual([0, 12, 24]); +}); +test('canonical suggestions stop scanning at 1000 even when all candidates are excluded', async () => { + const service = new Suggestions({ pool: { execute: jest.fn() } }); + const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) => + Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: 'test roof', total_hours: 2 }))); + const rows = await service.getOrderCandidateCases({}, ['roof'], 12); + expect(rows).toHaveLength(1000); + expect(candidates.mock.calls.at(-1)[0]).toMatchObject({ offset: 996, limit: 4 }); +}); +test.each(['findMaterials', 'searchDatabaseMaterials', 'searchAPIMaterials', 'findSimilarProjects', 'generateEstimate'])('unsafe enhanced %s fails closed before raw or API access', async method => { + const query = jest.fn(); + await expect(new Enhanced({ query })[method](['findSimilarProjects', 'generateEstimate'].includes(method) ? 'roof' : { keywords: ['roof'] })).rejects.toMatchObject({ code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE' }); + expect(query).not.toHaveBeenCalled(); +}); +test('live tag experience route fails closed without fabricated evidence', async () => { + const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8'); + const body = source.split("app.get('/api/customer-projects/tag-experience-suggestions'")[1].split('// Tag Geometry Intelligence')[0]; + const handler = new Function(`return async (req, res) => {${body.slice(body.indexOf('=> {') + 4, body.lastIndexOf('});'))}}`)(); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await handler({}, res); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE', suggestions: [] })); +}); + +test('canonical history scans past windows without a post-query match', async () => { + const History = require('../historicalSmartPackageSearchService'); + const service = new History({ pool: { execute: jest.fn() } }); + jest.spyOn(service, 'getOrdrestyringMaterialsByCase').mockResolvedValue(new Map()); + jest.spyOn(service, 'getOrdrestyringHoursByCase').mockResolvedValue(new Map()); + const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) => + Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: offset < 12 ? 'irrelevant' : 'roof', total_hours: 2 }))); + const rows = await service.getOrdrestyringCandidates('roof', ['roof'], 12); + expect(rows.filter(row => service.calculateMatch(row, 'roof', ['roof']).hasQuerySignal)).toHaveLength(12); + expect(candidates.mock.calls.map(([args]) => args.offset)).toEqual([0, 12]); +}); + +test('canonical suggestions reject nonfinite hours and deduplicate identities across windows', async () => { + const service = new Suggestions({ pool: { execute: jest.fn() } }); + jest.spyOn(service.readModel, 'candidates') + .mockResolvedValueOnce([ + { case_number: 'A', description: 'roof', total_hours: 1 }, + { case_number: 'BAD', description: 'roof', total_hours: Infinity } + ]) + .mockResolvedValueOnce([ + { case_number: 'A', description: 'roof', total_hours: 1 }, + { case_number: 'B', description: 'roof', total_hours: 2 } + ]); + const rows = await service.getOrderCandidateCases({}, ['roof'], 2); + expect(rows.map(row => row.case_number)).toEqual(['A', 'B']); +}); + +test.each([ + ['/api/enhanced-quote', { projectDescription: 'roof' }], + ['/api/search-materials', { keywords: ['roof'] }], + ['/api/similar-projects', { description: 'roof' }] +])('live enhanced consumer %s returns unavailable without quote evidence', async (route, body) => { + const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8'); + const routeStart = source.indexOf(`app.post('${route}'`); + expect(routeStart).toBeGreaterThan(-1); + const handlerStart = source.indexOf('=> {', routeStart) + 4; + const handlerEnd = source.indexOf('\n});', handlerStart); + const handler = new Function('enhancedOrderDataService', 'logger', `return async (req, res) => {${source.slice(handlerStart, handlerEnd)}}`)( + new Enhanced(), { info: jest.fn(), error: jest.fn() }); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await handler({ body }, res); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE' })); +}); diff --git a/backend/src/services/enhancedOrderDataService.js b/backend/src/services/enhancedOrderDataService.js index 93242fc..8011836 100644 --- a/backend/src/services/enhancedOrderDataService.js +++ b/backend/src/services/enhancedOrderDataService.js @@ -1,487 +1,18 @@ -const mysql = require('mysql2/promise'); -const axios = require('axios'); - +// These legacy estimators aggregate unclassified raw materials and amplified +// cases. Keep their public entry points unavailable until canonical evidence +// supports usage, prices, project totals and similarity rankings end to end. class EnhancedOrderDataService { - constructor(databaseService) { - this.db = databaseService; - this.apiBase = 'https://v2.api.ordrestyring.dk'; - this.apiToken = process.env.ORDRESTYRING_TOKEN; - } + unavailable() { + const error = new Error('Avancerede Ordrestyring-estimater mangler verificeret datagrundlag.'); + error.code = 'UNVERIFIED_ORDRESTYRING_EVIDENCE'; + throw error; + } - /** - * Intelligent material search: Database first, API fallback - */ - async findMaterials(searchCriteria) { - const results = { - source: 'hybrid', - database_results: [], - api_results: [], - total_matches: 0, - search_strategy: [] - }; - - try { - // 1. Search local database first - console.log('🔍 Searching local database for materials...'); - results.search_strategy.push('database_search'); - - const dbResults = await this.searchDatabaseMaterials(searchCriteria); - results.database_results = dbResults; - results.total_matches += dbResults.length; - - console.log(`📊 Found ${dbResults.length} matches in local database`); - - // 2. If we have good matches, use them - if (dbResults.length >= 3) { - results.source = 'database_primary'; - return results; - } - - // 3. If limited matches, supplement with API data - if (this.apiToken && dbResults.length < 5) { - console.log('🌐 Supplementing with live API data...'); - results.search_strategy.push('api_supplement'); - - const apiResults = await this.searchAPIMaterials(searchCriteria); - results.api_results = apiResults; - results.total_matches += apiResults.length; - results.source = 'hybrid'; - } - - return results; - - } catch (error) { - console.error('❌ Error in material search:', error); - throw error; - } - } - - /** - * Search local database for materials - */ - async searchDatabaseMaterials(criteria) { - const { keywords, category, priceRange, supplier } = criteria; - - try { - // Create connection to ordrestyring database - const mysql = require('mysql2/promise'); - const ordrestyringDb = mysql.createPool({ - host: process.env.ORDRESTYRING_DB_HOST, - user: process.env.ORDRESTYRING_DB_USER, - password: process.env.ORDRESTYRING_DB_PASSWORD, - database: process.env.ORDRESTYRING_DB_NAME, - waitForConnections: true, - connectionLimit: 5, - queueLimit: 0 - }); - - let query = ` - SELECT - cm.product_text, - COUNT(*) as usage_frequency, - ROUND(AVG(CAST(cm.sales_price AS DECIMAL(10,2))/100), 2) as price_dkk, - AVG(cm.quantity) as avg_quantity, - COUNT(DISTINCT cm.case_number) as project_count - FROM case_materials cm - WHERE cm.product_text IS NOT NULL - AND cm.product_text != '' - AND cm.sales_price > 0 - `; - - const params = []; - - // Add keyword search - if (keywords && keywords.length > 0) { - const keywordConditions = keywords.map(() => 'cm.product_text LIKE ?').join(' OR '); - query += ` AND (${keywordConditions})`; - keywords.forEach(keyword => params.push(`%${keyword}%`)); - } - - // Add supplier filter - if (supplier) { - query += ` AND cm.supplier LIKE ?`; - params.push(`%${supplier}%`); - } - - // Add price range - if (priceRange) { - if (priceRange.min) { - query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 >= ?`; - params.push(priceRange.min); - } - if (priceRange.max) { - query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 <= ?`; - params.push(priceRange.max); - } - } - - query += ` - GROUP BY cm.product_text - ORDER BY usage_frequency DESC, price_dkk ASC - LIMIT 20 - `; - - const [results] = await ordrestyringDb.execute(query, params); - - // Close the connection - await ordrestyringDb.end(); - - // Group similar materials and calculate statistics - return this.processDatabaseResults(results); - - } catch (error) { - console.error('❌ Error searching ordrestyring database:', error); - // Return empty results if database search fails - return []; - } - } - - /** - * Search API for materials (fallback) - */ - async searchAPIMaterials(criteria) { - if (!this.apiToken) return []; - - try { - const config = { - method: 'GET', - url: `${this.apiBase}/case-materials`, - auth: { - username: this.apiToken, - password: 'x' - }, - params: { - pagesize: 50 - } - }; - - const response = await axios(config); - const materials = Object.values(response.data || {}); - - // Filter API results based on criteria - return this.filterAPIResults(materials, criteria); - - } catch (error) { - console.error('❌ API search failed:', error); - return []; - } - } - - /** - * Process database results to add statistics - */ - processDatabaseResults(results) { - const processed = results.map(item => ({ - ...item, - source: 'database', - confidence: this.calculateConfidence(item), - price_stats: this.calculatePriceStats(item) - })); - - return processed; - } - - /** - * Filter API results based on search criteria - */ - filterAPIResults(materials, criteria) { - const { keywords } = criteria; - - return materials - .filter(material => { - if (!material.product_text) return false; - - if (keywords && keywords.length > 0) { - return keywords.some(keyword => - material.product_text.toLowerCase().includes(keyword.toLowerCase()) - ); - } - - return true; - }) - .slice(0, 10) - .map(material => ({ - ...material, - source: 'api', - price_dkk: parseFloat(material.sales_price || 0) / 100, - confidence: 0.7 // Lower confidence for API data - })); - } - - /** - * Calculate confidence score for materials - */ - calculateConfidence(item) { - let confidence = 0.5; - - // Higher confidence for frequently used materials - if (item.usage_frequency > 10) confidence += 0.3; - else if (item.usage_frequency > 5) confidence += 0.2; - else if (item.usage_frequency > 2) confidence += 0.1; - - // Higher confidence for recent data - if (item.case_number && parseInt(item.case_number) > 1500) confidence += 0.1; - - // Higher confidence for known suppliers - if (item.supplier && item.supplier.includes('Bygma')) confidence += 0.1; - - return Math.min(confidence, 1.0); - } - - /** - * Calculate price statistics for a material - */ - calculatePriceStats(item) { - return { - current_price: item.price_dkk, - cost_margin: item.cost_dkk ? ((item.price_dkk - item.cost_dkk) / item.cost_dkk * 100).toFixed(1) : null, - usage_frequency: item.usage_frequency - }; - } - - /** - * Find similar projects based on description analysis - */ - async findSimilarProjects(description, projectType = null) { - try { - const keywords = this.extractKeywords(description); - console.log(`🔍 Finding similar projects for keywords: ${keywords.join(', ')}`); - - // Build dynamic query to find similar projects - const whereConditions = []; - const params = []; - - // Add keyword matching - keywords.forEach(keyword => { - whereConditions.push('c.description LIKE ?'); - params.push(`%${keyword}%`); - }); - - // Simplified query for better performance - let query = ` - SELECT - c.case_number, - c.description, - c.creation_date, - COUNT(cm.id) as material_count, - COALESCE(SUM(cm.sales_price), 0) as total_material_cost - FROM cases c - LEFT JOIN case_materials cm ON c.case_number = cm.case_number AND cm.sales_price > 0 - WHERE ${whereConditions.join(' OR ')} - AND c.description IS NOT NULL - AND c.description != '' - GROUP BY c.case_number, c.description, c.creation_date - HAVING COUNT(cm.id) > 0 AND COALESCE(SUM(cm.sales_price), 0) > 1000 - ORDER BY COALESCE(SUM(cm.sales_price), 0) DESC - LIMIT 10 - `; - - const results = await this.db.query(query, params); - - // Calculate estimated totals and clean data - const projects = results.map(row => ({ - case_number: row.case_number, - description: row.description || 'Ingen beskrivelse', - total_material_cost: Math.max(0, parseFloat(row.total_material_cost) || 0), - total_hours: 40, // Default estimate for labor - avg_hourly_rate: 800, // Standard hourly rate - material_count: parseInt(row.material_count) || 0, - estimated_total: 0, // Will be calculated below - creation_date: row.creation_date - })); - - // Calculate estimated totals (materials + estimated labor) - projects.forEach(project => { - // Simple estimation: 40 hours labor for most projects - const laborCost = project.total_hours * project.avg_hourly_rate; - project.estimated_total = project.total_material_cost + laborCost; - }); - - console.log(`✅ Found ${projects.length} similar projects`); - return projects; - - } catch (error) { - console.error('❌ Error finding similar projects:', error); - return []; - } - } - - /** - * Extract keywords from project description - */ - extractKeywords(description) { - const commonKeywords = [ - 'køkken', 'bad', 'badeværelse', 'toilet', 'loft', 'gulv', 'væg', 'dør', 'vindue', - 'maling', 'fliser', 'gips', 'isolering', 'el', 'vvs', 'tømrer', 'murer', - 'renovering', 'ombygning', 'tilbygning', 'reparation', 'montering', 'terrasse', - 'tag', 'facade', 'kælder', 'badekar', 'bruser', 'skabe', 'bordplade', 'granit', - 'teak', 'træ', 'belysning', 'gelænder', 'hvidevarer', 'montage' - ]; - - const words = description.toLowerCase().split(/\s+/); - const foundKeywords = words.filter(word => { - // Exact match with common keywords - if (commonKeywords.includes(word)) return true; - // Partial match for longer words - if (word.length > 4) { - return commonKeywords.some(keyword => - word.includes(keyword) || keyword.includes(word) - ); - } - return false; - }); - - // Also include longer words that might be important - const importantWords = words.filter(word => word.length > 5); - - return [...new Set([...foundKeywords, ...importantWords])].slice(0, 8); - } - - /** - * Calculate project similarity confidence - */ - calculateProjectConfidence(project, searchKeywords) { - let confidence = 0.3; - - const projectWords = project.description.toLowerCase().split(/\s+/); - const matchingKeywords = searchKeywords.filter(keyword => - projectWords.some(word => word.includes(keyword)) - ); - - confidence += (matchingKeywords.length / searchKeywords.length) * 0.4; - - // Recent projects get higher confidence - if (project.case_number && parseInt(project.case_number) > 1500) confidence += 0.2; - - // Projects with more materials get higher confidence - if (project.material_count > 20) confidence += 0.1; - - return Math.min(confidence, 1.0); - } - - /** - * Generate comprehensive estimate - */ - async generateEstimate(projectDescription, requirements = {}) { - console.log('🎯 Generating comprehensive estimate...'); - - const estimate = { - project_description: projectDescription, - timestamp: new Date().toISOString(), - data_sources: [], - materials: { - database_matches: [], - api_supplements: [], - estimated_cost: 0 - }, - labor: { - estimated_hours: 0, - hourly_rate: 0, - total_cost: 0 - }, - similar_projects: [], - total_estimate: 0, - confidence_score: 0 - }; - - try { - // 1. Find similar projects - const similarProjects = await this.findSimilarProjects(projectDescription); - estimate.similar_projects = similarProjects; - estimate.data_sources.push({ - type: 'historical_projects', - count: similarProjects.length, - confidence: similarProjects.length > 0 ? Math.min(90, 30 + (similarProjects.length * 10)) : 30 - }); - - // 2. Search for relevant materials - const materialCriteria = { - keywords: this.extractKeywords(projectDescription), - priceRange: requirements.budget ? { max: requirements.budget * 0.6 } : null - }; - - const materialResults = await this.findMaterials(materialCriteria); - estimate.materials.database_matches = materialResults.database_results; - estimate.materials.api_supplements = materialResults.api_results; - estimate.data_sources.push({ - type: materialResults.source, - count: materialResults.total_matches, - confidence: materialResults.database_results.length > 0 ? - Math.min(95, 50 + (materialResults.database_results.length * 2)) : 30 - }); - - // 3. Calculate estimates based on similar projects - if (similarProjects.length > 0) { - const validProjects = similarProjects.filter(p => - p.total_material_cost >= 0 && - p.total_hours >= 0 && - p.total_hours <= 1000 && // Reasonable upper limit - p.avg_hourly_rate >= 100 && - p.avg_hourly_rate <= 3000 - ); - - if (validProjects.length > 0) { - const avgMaterialCost = validProjects.reduce((sum, p) => sum + (p.total_material_cost || 0), 0) / validProjects.length; - const avgHours = validProjects.reduce((sum, p) => sum + (p.total_hours || 0), 0) / validProjects.length; - const avgHourlyRate = validProjects.reduce((sum, p) => sum + (p.avg_hourly_rate || 800), 0) / validProjects.length; - - estimate.materials.estimated_cost = Math.round(Math.max(0, avgMaterialCost)); - estimate.labor.estimated_hours = Math.round(Math.max(0, Math.min(500, avgHours))); // Cap at 500 hours - estimate.labor.hourly_rate = Math.round(Math.max(400, Math.min(2000, avgHourlyRate))); // 400-2000 kr/hour - estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate); - } else { - // Fallback estimates if no valid projects - estimate.materials.estimated_cost = Math.round((options.budget || 100000) * 0.6); // 60% materials - estimate.labor.estimated_hours = Math.round((options.budget || 100000) / 800 * 0.4 / 800); // 40% labor - estimate.labor.hourly_rate = 800; - estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate); - } - } else { - // No similar projects found - use budget-based estimates - const budgetBased = requirements.budget || 100000; - estimate.materials.estimated_cost = Math.round(budgetBased * 0.65); - estimate.labor.estimated_hours = Math.round(budgetBased * 0.35 / 800); - estimate.labor.hourly_rate = 800; - estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate); - } - - // 4. Calculate total and confidence - estimate.total_estimate = Math.round(estimate.materials.estimated_cost + estimate.labor.total_cost); - estimate.confidence_score = this.calculateOverallConfidence(estimate); - - console.log(`✅ Estimate generated: ${estimate.total_estimate} kr (confidence: ${(estimate.confidence_score * 100).toFixed(1)}%)`); - - return estimate; - - } catch (error) { - console.error('❌ Error generating estimate:', error); - throw error; - } - } - - /** - * Calculate overall confidence for the estimate - */ - calculateOverallConfidence(estimate) { - let confidence = 0.2; - - // More similar projects = higher confidence - if (estimate.similar_projects.length > 3) confidence += 0.3; - else if (estimate.similar_projects.length > 1) confidence += 0.2; - - // More material matches = higher confidence - if (estimate.materials.database_matches.length > 5) confidence += 0.2; - else if (estimate.materials.database_matches.length > 2) confidence += 0.1; - - // Recent data = higher confidence - const recentProjects = estimate.similar_projects.filter(p => parseInt(p.case_number) > 1500); - if (recentProjects.length > 0) confidence += 0.2; - - // Multiple data sources = higher confidence - if (estimate.data_sources.length > 1) confidence += 0.1; - - return Math.min(confidence, 1.0); - } + async findMaterials() { return this.unavailable(); } + async searchDatabaseMaterials() { return this.unavailable(); } + async searchAPIMaterials() { return this.unavailable(); } + async findSimilarProjects() { return this.unavailable(); } + async generateEstimate() { return this.unavailable(); } } module.exports = EnhancedOrderDataService; diff --git a/backend/src/services/historicalSmartPackageSearchService.js b/backend/src/services/historicalSmartPackageSearchService.js index 65bf46d..bbd7791 100644 --- a/backend/src/services/historicalSmartPackageSearchService.js +++ b/backend/src/services/historicalSmartPackageSearchService.js @@ -509,9 +509,9 @@ class HistoricalSmartPackageSearchService { }]; } - async getOrdrestyringCaseCandidates(query, tokens, candidateLimit) { + async getOrdrestyringCaseCandidates(query, tokens, candidateLimit, offset = 0) { return this.readModel.read('history', () => this.getOrdrestyringCaseCandidatesLegacy(query, tokens, candidateLimit), async () => { - return this.readModel.candidates({ tokens: [query, ...tokens], limit: candidateLimit }); + return this.readModel.candidates({ tokens: [query, ...tokens], limit: candidateLimit, offset }); }); } @@ -971,8 +971,30 @@ class HistoricalSmartPackageSearchService { })); } - async getOrdrestyringCandidates(query, tokens, candidateLimit) { - const caseCandidates = await this.getOrdrestyringCaseCandidates(query, tokens, candidateLimit); + async getOrdrestyringCandidates(query, tokens, candidateLimit, eligibleLimit = candidateLimit) { + if (this.readModel.mode('history') !== 'canonical') { + return this.getOrdrestyringCandidateWindow(query, tokens, candidateLimit); + } + const candidates = new Map(); + let eligibleCount = 0; + for (let offset = 0; offset < 1000;) { + const windowSize = Math.min(Math.max(1, candidateLimit), 1000 - offset); + const rows = await this.getOrdrestyringCandidateWindow(query, tokens, windowSize, offset); + for (const row of rows) { + if (candidates.has(row.case_number) || !Number.isFinite(Number(row.total_work_hours)) + || !(Number(row.total_work_hours) > 0)) continue; + candidates.set(row.case_number, row); + const match = this.calculateMatch(row, query, tokens); + if (match.score > 0 && match.hasQuerySignal) eligibleCount += 1; + } + offset += windowSize; + if (eligibleCount >= eligibleLimit || rows.length < windowSize) break; + } + return [...candidates.values()]; + } + + async getOrdrestyringCandidateWindow(query, tokens, candidateLimit, offset = 0) { + const caseCandidates = await this.getOrdrestyringCaseCandidates(query, tokens, candidateLimit, offset); const caseNumbers = caseCandidates.map(candidate => candidate.case_number).filter(Boolean); const [materialsByCase, hoursByCase, offerCandidates] = await Promise.all([ this.getOrdrestyringMaterialsByCase(caseNumbers), @@ -1035,7 +1057,7 @@ class HistoricalSmartPackageSearchService { }) ); - const ordrestyringCandidates = await this.getOrdrestyringCandidates(normalizedQuery, tokens, candidateLimit); + const ordrestyringCandidates = await this.getOrdrestyringCandidates(normalizedQuery, tokens, candidateLimit, limit); const enrichedOrdrestyringCandidates = ordrestyringCandidates.map((candidate) => { const matchInfo = this.calculateMatch(candidate, normalizedQuery, tokens); return { diff --git a/backend/src/services/orderSuggestionService.js b/backend/src/services/orderSuggestionService.js index 851a4f7..d17fc1c 100644 --- a/backend/src/services/orderSuggestionService.js +++ b/backend/src/services/orderSuggestionService.js @@ -1044,9 +1044,25 @@ class OrderSuggestionService { }; } - async getOrderCandidateCases(project, tokens, candidateLimit) { + async getOrderCandidateCases(project, tokens, candidateLimit, eligibleLimit = candidateLimit) { return this.readModel.read('suggestions', () => this.getOrderCandidateCasesLegacy(project, tokens, candidateLimit), async () => { - return this.readModel.candidates({ tokens, customerNumber: project.customer_number, customerName: project.customer_name, limit: candidateLimit }); + const candidates = new Map(); + let eligibleCount = 0; + for (let offset = 0; offset < 1000;) { + const windowSize = Math.min(Math.max(1, candidateLimit), 1000 - offset); + const rows = await this.readModel.candidates({ tokens, customerNumber: project.customer_number, + customerName: project.customer_name, limit: windowSize, offset }); + for (const row of rows) { + if (candidates.has(row.case_number) || !Number.isFinite(Number(row.total_hours)) + || !(Number(row.total_hours) > 0)) continue; + candidates.set(row.case_number, row); + if (!this.getHistoryCandidateExclusions(project, row).length + && this.calculateOrderCandidateScore(project, row, tokens).relevanceScore > 0) eligibleCount += 1; + } + offset += windowSize; + if (eligibleCount >= eligibleLimit || rows.length < windowSize) break; + } + return [...candidates.values()]; }); } @@ -1583,7 +1599,7 @@ class OrderSuggestionService { let orderCandidates = []; let ordrestyringFallbackWarning = null; try { - orderCandidates = await this.getOrderCandidateCases(project, tokens, candidateLimit); + orderCandidates = await this.getOrderCandidateCases(project, tokens, candidateLimit, limit); } catch (error) { if (this.readModel.mode('suggestions') === 'canonical') throw error; ordrestyringFallbackWarning = 'Ordrestyring historik kunne ikke hentes nu. Viser lokale projekter i stedet.'; diff --git a/backend/src/services/ordrestyringPreflight.js b/backend/src/services/ordrestyringPreflight.js new file mode 100644 index 0000000..ecbe43a --- /dev/null +++ b/backend/src/services/ordrestyringPreflight.js @@ -0,0 +1,38 @@ +const { OrdrestyringReadModel } = require('./ordrestyringReadModel'); + +const projections = { + cases: 'ordrestyring_cases_current', + debtors: 'ordrestyring_debtors_current', + materials: 'ordrestyring_material_semantics' +}; + +// Read-only boot gate. Operators must run the explicit migration before boot; +// HTTP requests and startup must never repair an absent or partial projection. +async function assertOrdrestyringReadiness(connection, env = process.env) { + const model = new OrdrestyringReadModel(connection, { env }); + if (Object.values(model.modes).every(mode => mode === 'legacy')) return { ready: true, mode: 'legacy' }; + const account = env.ORDRESTYRING_SOURCE_ACCOUNT; + if (typeof account !== 'string' || !/^[a-zA-Z0-9._:-]{1,128}$/.test(account)) { + throw new Error('Ordrestyring preflight requires ORDRESTYRING_SOURCE_ACCOUNT'); + } + const fail = reason => { throw new Error(`Ordrestyring canonical preflight failed: ${reason}. Run the explicit canonical migration before starting.`); }; + const tables = [...Object.values(projections), 'ordrestyring_projection_state']; + const rows = await model.rows(`SELECT TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'ordrestyring_local' AND TABLE_NAME IN (?, ?, ?, ?)`, tables); + if (!tables.every(table => rows.some(row => row.table_name === table))) fail('missing canonical tables'); + const states = await model.rows(`SELECT projection_kind, source_account, row_count, sync_run_id, mirrored_at + FROM ordrestyring_local.ordrestyring_projection_state`); + for (const [kind, table] of Object.entries(projections)) { + const state = states.find(row => row.projection_kind === kind); + if (!state || state.source_account !== account || !state.sync_run_id || !state.mirrored_at + || !Number.isSafeInteger(Number(state.row_count)) || Number(state.row_count) <= 0) fail(`${kind} projection is not ready`); + const [counts] = await model.rows(`SELECT COUNT(*) AS actual_count, + SUM(CASE WHEN BINARY source_account = BINARY ? THEN 1 ELSE 0 END) AS account_count + FROM ordrestyring_local.${table}`, [account]); + if (Number(counts?.actual_count) !== Number(state.row_count) + || Number(counts?.account_count) !== Number(state.row_count)) fail(`${kind} count/account mismatch`); + } + return { ready: true, mode: 'canonical' }; +} + +module.exports = { assertOrdrestyringReadiness }; diff --git a/backend/src/services/ordrestyringReadModel.js b/backend/src/services/ordrestyringReadModel.js index 2811863..4dab35a 100644 --- a/backend/src/services/ordrestyringReadModel.js +++ b/backend/src/services/ordrestyringReadModel.js @@ -19,6 +19,8 @@ const casesRelation = `(SELECT case_number, case_number AS id, ${fields(['custom const debtorsRelation = `(SELECT customer_number, ${fields(['customer_name', 'customer_address', 'customer_telephone', 'customer_email'])} FROM ordrestyring_local.ordrestyring_debtors_current)`; const seconds = name => `(CASE WHEN ${name} > 100000000000 THEN ${name} / 1000 ELSE ${name} END)`; const hoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours WHERE start_time > 0 AND ${seconds('stop_time')} > ${seconds('start_time')})`; +// Planning also represents local allocations whose end is not known yet. +const planningHoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours WHERE start_time > 0 AND (stop_time = 0 OR ${seconds('stop_time')} > ${seconds('start_time')}))`; const parse = value => typeof value === 'string' ? JSON.parse(value) : value; class OrdrestyringReadModel { @@ -92,7 +94,7 @@ class OrdrestyringReadModel { } /** Unique current relations and preaggregated hours prevent crowd-out before LIMIT. @returns {Promise} */ - async candidates({ tokens = [], customerNumber, customerName, limit = 40, regex } = {}) { + async candidates({ tokens = [], customerNumber, customerName, limit = 40, offset = 0, regex } = {}) { const clauses = []; const params = []; if (customerNumber) { clauses.push('c.customer_number = ?'); params.push(customerNumber); } @@ -108,7 +110,7 @@ class OrdrestyringReadModel { JOIN (SELECT new_case_number, SUM((stop_time - start_time) / 3600) AS total_hours, COUNT(*) AS hour_entries FROM ${hoursRelation} verified_hours GROUP BY new_case_number) h ON h.new_case_number = c.case_number WHERE ${clauses.length ? `(${clauses.join(' OR ')})` : '1=1'} - ORDER BY latest_activity_at DESC, c.case_number ASC LIMIT ?`, [...params, Math.max(1, Math.min(1000, Math.floor(limit)))]); + ORDER BY latest_activity_at DESC, c.case_number ASC LIMIT ? OFFSET ?`, [...params, Math.max(1, Math.min(1000, Math.floor(limit))), Math.max(0, Math.min(1000, Math.floor(offset)))]); } async hours(numbers) { @@ -172,4 +174,4 @@ class OrdrestyringReadModel { } } -module.exports = { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation }; +module.exports = { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation, planningHoursRelation }; diff --git a/backend/src/services/planningService.js b/backend/src/services/planningService.js index cb98310..debe4b7 100644 --- a/backend/src/services/planningService.js +++ b/backend/src/services/planningService.js @@ -1,4 +1,4 @@ -const { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation } = require('./ordrestyringReadModel'); +const { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation, planningHoursRelation } = require('./ordrestyringReadModel'); const logger = require('../utils/logger'); /** @@ -164,7 +164,7 @@ class PlanningService { h.new_case_number, c.description as case_description, c.yourref - FROM ${canonical ? hoursRelation : `${this.ORDRESTYRING_DB}.hours`} h + FROM ${canonical ? planningHoursRelation : `${this.ORDRESTYRING_DB}.hours`} h LEFT JOIN ${canonical ? casesRelation : `${this.ORDRESTYRING_DB}.cases`} c ON h.new_case_number = c.case_number WHERE h.emp_id = ? AND h.start_time >= ? @@ -240,7 +240,7 @@ class PlanningService { u.first_name, u.last_name, u.init - FROM ${canonical ? hoursRelation : `${this.ORDRESTYRING_DB}.hours`} h + FROM ${canonical ? planningHoursRelation : `${this.ORDRESTYRING_DB}.hours`} h LEFT JOIN ${canonical ? casesRelation : `${this.ORDRESTYRING_DB}.cases`} c ON h.new_case_number = c.case_number LEFT JOIN ${this.ORDRESTYRING_DB}.case_statuses cs ON c.status = cs.id LEFT JOIN ${this.ORDRESTYRING_DB}.users u ON h.emp_id = u.id @@ -308,6 +308,14 @@ class PlanningService { } } + async validateCase(caseNumber) { + if (!caseNumber) return; + const cases = await this.readModel.read('planning', + () => this.db.query(`SELECT case_number FROM ${this.ORDRESTYRING_DB}.cases WHERE case_number = ?`, [caseNumber]), + () => this.readModel.cases([caseNumber])); + if (!cases.length) throw new Error('Case not found'); + } + /** * Opret ny medarbejder allokering * @param {Object} allocationData - Allocation details @@ -334,17 +342,7 @@ class PlanningService { throw new Error('Employee not found or inactive'); } - // Validate case exists - if (case_number) { - const caseCheck = await this.db.query( - `SELECT case_number FROM ${this.ORDRESTYRING_DB}.cases WHERE case_number = ?`, - [case_number] - ); - - if (caseCheck.length === 0) { - throw new Error('Case not found'); - } - } + await this.validateCase(case_number); // Create allocation entry const insertQuery = ` @@ -408,6 +406,8 @@ class PlanningService { case_number } = updateData; + await this.validateCase(case_number); + const updateQuery = ` UPDATE ${this.ORDRESTYRING_DB}.hours SET diff --git a/backend/unified-server.js b/backend/unified-server.js index bd151d2..bedb70c 100644 --- a/backend/unified-server.js +++ b/backend/unified-server.js @@ -170,6 +170,7 @@ const healthPool = mysql.createPool({ // Import logger const logger = require('./src/utils/logger'); +const { assertOrdrestyringReadiness } = require('./src/services/ordrestyringPreflight'); const { bootstrapServiceContainer } = require('./src/services/bootstrapServiceContainer'); const { calculateQuoteEconomics } = require('./src/services/quoteEconomicsService'); const { sendPdfDownload } = require('./src/services/pdfResponseService'); @@ -5042,149 +5043,12 @@ app.get('/health', (req, res) => { // Tag experience suggestions - must come before parametrized routes app.get('/api/customer-projects/tag-experience-suggestions', verifyToken, requireConfiguredProjectOperator, async (req, res) => { - try { - console.log('🏷️ Fetching tag experience suggestions from Ordrestyring database...'); - - let laborSuggestions = []; - - // Hent faktiske tag-cases fra Ordrestyring database - try { - console.log('📊 Querying ordrestyring_local database for tag cases...'); - - // Use the existing databaseService with cross-database query - const tagCases = await databaseService.query(` - SELECT - c.case_number, - c.description, - c.work_done, - c.remarks - FROM ordrestyring_local.cases c - WHERE c.description REGEXP 'tag|eternit|tegl|rende|velux|rygning' - AND c.case_number IS NOT NULL - AND c.description IS NOT NULL - AND c.description != '' - ORDER BY c.creation_date DESC - LIMIT 15 - `); - - console.log(`📋 Found ${tagCases.length} actual tag cases from Ordrestyring`); - - // Transform Ordrestyring cases til labor suggestions - for (const tagCase of tagCases) { - const hours = 12; // Default reasonable hours for tag work - const hourlyRate = 550; // Standard rate - const totalCost = hours * hourlyRate; - - // Extract main task from description - let taskDescription = tagCase.description; - if (taskDescription && taskDescription.length > 100) { - taskDescription = taskDescription.substring(0, 97) + '...'; - } - - laborSuggestions.push({ - task_description: taskDescription || 'Tag arbejde', - estimated_hours: hours, - hourly_rate: hourlyRate, - total_cost: totalCost, - category: 'tag_arbejde', - confidence: 0.8, // Good confidence - real case data - source: 'ordrestyring_database', - case_number: tagCase.case_number, - work_notes: tagCase.work_done || tagCase.remarks || 'Ingen noter tilgængelige' - }); - } - - console.log(`✅ Converted ${laborSuggestions.length} cases to labor suggestions`); - - } catch (dbError) { - console.error('❌ Database error when fetching tag cases:', dbError); - // Continue to fallback - } - - // Final fallback to default suggestions - if (laborSuggestions.length === 0) { - console.log('🔄 Using default tag suggestions'); - laborSuggestions = [ - { - task_description: 'Skift af B7 eternitplader - standard tag', - estimated_hours: 16, - hourly_rate: 550, - total_cost: 8800, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - }, - { - task_description: 'Reparation af tagrender og nedløb', - estimated_hours: 8, - hourly_rate: 500, - total_cost: 4000, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - }, - { - task_description: 'Montering af Velux ovenlysvindue', - estimated_hours: 6, - hourly_rate: 580, - total_cost: 3480, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - } - ]; - } - - console.log(`📋 Returning ${laborSuggestions.length} tag experience suggestions`); - - res.json({ - success: true, - suggestions: laborSuggestions, - message: `Fundet ${laborSuggestions.length} erfaringer fra tidligere tag-opgaver`, - timestamp: new Date().toISOString() - }); - - } catch (error) { - console.error('Error fetching tag experience suggestions:', error); - - // Fallback to default suggestions if everything fails - const defaultSuggestions = [ - { - task_description: 'Skift af B7 eternitplader - standard tag', - estimated_hours: 16, - hourly_rate: 550, - total_cost: 8800, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - }, - { - task_description: 'Reparation af tagrender og nedløb', - estimated_hours: 8, - hourly_rate: 500, - total_cost: 4000, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - }, - { - task_description: 'Montering af Velux ovenlysvindue', - estimated_hours: 6, - hourly_rate: 580, - total_cost: 3480, - category: 'tag_arbejde', - confidence: 0.6, - source: 'default_template' - } - ]; - - res.json({ - success: true, - suggestions: defaultSuggestions, - message: 'Viser standard tag-opgave skabeloner (historiske data ikke tilgængelige)', - timestamp: new Date().toISOString() - }); - } + return res.status(503).json({ + success: false, + code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE', + suggestions: [], + message: 'Historiske tagforslag er deaktiveret, indtil verificeret timegrundlag er tilgængeligt.' + }); }); // Tag Geometry Intelligence - Smart estimater baseret på lokal Ordrestyring database @@ -10336,6 +10200,9 @@ app.post('/api/enhanced-quote', async (req, res) => { } catch (error) { logger.error('❌ Enhanced quote generation failed:', error); + if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') { + return res.status(503).json({ success: false, code: error.code, error: error.message }); + } res.status(500).json({ error: 'Fejl ved generering af avanceret tilbud', details: error.message, @@ -10376,6 +10243,9 @@ app.post('/api/search-materials', async (req, res) => { } catch (error) { logger.error('❌ Material search failed:', error); + if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') { + return res.status(503).json({ success: false, code: error.code, error: error.message }); + } res.status(500).json({ error: 'Fejl ved materialesøgning', details: error.message @@ -10409,6 +10279,9 @@ app.post('/api/similar-projects', async (req, res) => { } catch (error) { logger.error('❌ Similar projects search failed:', error); + if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') { + return res.status(503).json({ success: false, code: error.code, error: error.message }); + } res.status(500).json({ error: 'Fejl ved søgning efter lignende projekter', details: error.message @@ -11319,6 +11192,7 @@ app.get('*', (req, res) => { // Start server const startServer = async () => { + await assertOrdrestyringReadiness(db); // Initialize backend services const backendReady = await initializeBackend(); if (!backendReady) throw new Error('Backend initialization failed; refusing to listen');