diff --git a/.gitignore b/.gitignore index e2cb231..af1e875 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ logs/ *.log /backend/error.log /backend/combined.log +combined.log +error.log +cron_sync.log # Runtime data pids @@ -63,3 +66,16 @@ core.* tmp/ temp/ frontend/firmadata/subvendor/bygma/bygma_esg_data.csv + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Test outputs +test-results/ +playwright-report/ +*.pdf +!docs/**/*.pdf diff --git a/FEATURE_ROADMAP.md b/FEATURE_ROADMAP.md deleted file mode 100644 index efaccda..0000000 --- a/FEATURE_ROADMAP.md +++ /dev/null @@ -1,2195 +0,0 @@ -# 🗺️ Feature Roadmap - Tilbudgivern - -**Oprettet**: 2025-10-19 -**Status**: Planning Phase - ---- - -## 📋 Feature Overview - -| # | Feature | Prioritet | Status | Estimeret Tid | -|---|---------|-----------|--------|---------------| -| 1 | Standardisering af opgavebeskrivelser | 🔴 Høj | 📋 Planlagt | 3-5 dage | -| 2 | Geometri-beregning af tag | 🔴 Høj | 📋 Planlagt | 2-3 dage | -| 3 | Smart pakker udvidelse | 🟡 Medium | 📋 Planlagt | 4-6 dage | -| 4 | AI-baseret kundebeskrivelse | 🟢 Lav | 📋 Planlagt | 3-4 dage | -| 5 | PDF-preview i final review | 🟡 Medium | 📋 Planlagt | 2-3 dage | -| 6 | Web-scraper til montagevejledninger | 🟢 Lav | 📋 Planlagt | 5-7 dage | - -**Total Estimeret Tid**: 19-28 dage - ---- - -## 1️⃣ Standardisering af Opgavebeskrivelser - -### 📝 Beskrivelse -Indfør key phrases (vinduer, døre, gulve, tage) så data kan segmenteres til erfaringspakker. - -### 🎯 Acceptance Criteria -- [x] System genkender nøgleord i beskrivelser -- [x] Beskrivelser mappe til standardiserede kategorier -- [x] Kan eksportere statistik pr. kategori - -### 🔧 Teknisk Implementation - -#### Database Schema -```sql --- Ny tabel til standardiserede kategorier -CREATE TABLE task_categories ( - id INT AUTO_INCREMENT PRIMARY KEY, - category_name VARCHAR(100) NOT NULL, - category_key VARCHAR(50) UNIQUE NOT NULL, - description TEXT, - icon VARCHAR(50), - color_code VARCHAR(7), - sort_order INT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Key phrases til kategori matching -CREATE TABLE category_keywords ( - id INT AUTO_INCREMENT PRIMARY KEY, - category_id INT NOT NULL, - keyword VARCHAR(100) NOT NULL, - weight DECIMAL(3,2) DEFAULT 1.0, - FOREIGN KEY (category_id) REFERENCES task_categories(id), - INDEX idx_keyword (keyword) -); - --- Link opgaver til kategorier -ALTER TABLE project_tasks -ADD COLUMN category_id INT, -ADD COLUMN auto_categorized BOOLEAN DEFAULT FALSE, -ADD COLUMN category_confidence DECIMAL(3,2), -ADD FOREIGN KEY (category_id) REFERENCES task_categories(id); -``` - -#### Service: TaskCategorizationService -```javascript -// backend/src/services/taskCategorizationService.js - -class TaskCategorizationService { - - constructor() { - this.categories = { - vinduer: { - keywords: ['vindu', 'vinduer', 'termoruder', 'karm', 'karme', 'ruder'], - weight: { exact: 1.0, partial: 0.7 } - }, - døre: { - keywords: ['dør', 'døre', 'entre', 'entredør', 'indgang', 'udgang'], - weight: { exact: 1.0, partial: 0.7 } - }, - gulve: { - keywords: ['gulv', 'gulve', 'trægulv', 'laminat', 'parket', 'vinyl', 'fliser'], - weight: { exact: 1.0, partial: 0.7 } - }, - tage: { - keywords: ['tag', 'tage', 'tagplader', 'spær', 'lægter', 'undertag', 'tagrende'], - weight: { exact: 1.0, partial: 0.7 } - }, - vægge: { - keywords: ['væg', 'vægge', 'facade', 'puds', 'maling', 'isolering'], - weight: { exact: 1.0, partial: 0.7 } - }, - el: { - keywords: ['el', 'elektriker', 'stikkontakt', 'belysning', 'ledninger'], - weight: { exact: 1.0, partial: 0.7 } - }, - vvs: { - keywords: ['vvs', 'rør', 'badeværelse', 'køkken', 'vandhane', 'afløb'], - weight: { exact: 1.0, partial: 0.7 } - } - }; - } - - /** - * Kategoriser en opgave baseret på beskrivelse - * @param {string} description - Opgavebeskrivelse - * @returns {Object} - { category, confidence, matchedKeywords } - */ - categorizeTask(description) { - const normalizedDesc = description.toLowerCase(); - const scores = {}; - - for (const [category, config] of Object.entries(this.categories)) { - let score = 0; - const matchedKeywords = []; - - for (const keyword of config.keywords) { - // Exact match - if (normalizedDesc.includes(keyword)) { - score += config.weight.exact; - matchedKeywords.push({ keyword, type: 'exact' }); - } - // Partial match (fuzzy) - else if (this.fuzzyMatch(normalizedDesc, keyword)) { - score += config.weight.partial; - matchedKeywords.push({ keyword, type: 'partial' }); - } - } - - if (score > 0) { - scores[category] = { - score, - matchedKeywords, - confidence: Math.min(score / config.keywords.length, 1.0) - }; - } - } - - // Find højeste score - const bestMatch = Object.entries(scores) - .sort(([, a], [, b]) => b.score - a.score)[0]; - - if (!bestMatch) { - return { category: 'andre', confidence: 0, matchedKeywords: [] }; - } - - const [category, data] = bestMatch; - return { - category, - confidence: data.confidence, - matchedKeywords: data.matchedKeywords - }; - } - - /** - * Fuzzy string matching - */ - fuzzyMatch(text, keyword) { - const threshold = 0.8; - const distance = this.levenshteinDistance(text, keyword); - const similarity = 1 - (distance / Math.max(text.length, keyword.length)); - return similarity >= threshold; - } - - /** - * Levenshtein distance for fuzzy matching - */ - levenshteinDistance(str1, str2) { - const matrix = []; - - for (let i = 0; i <= str2.length; i++) { - matrix[i] = [i]; - } - - for (let j = 0; j <= str1.length; j++) { - matrix[0][j] = j; - } - - for (let i = 1; i <= str2.length; i++) { - for (let j = 1; j <= str1.length; j++) { - if (str2.charAt(i - 1) === str1.charAt(j - 1)) { - matrix[i][j] = matrix[i - 1][j - 1]; - } else { - matrix[i][j] = Math.min( - matrix[i - 1][j - 1] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j] + 1 - ); - } - } - } - - return matrix[str2.length][str1.length]; - } - - /** - * Batch kategoriser alle opgaver i et projekt - */ - async categorizeProjectTasks(projectId) { - const tasks = await db.query( - 'SELECT id, task_description FROM project_tasks WHERE project_id = ?', - [projectId] - ); - - const results = []; - - for (const task of tasks) { - const categorization = this.categorizeTask(task.task_description); - - await db.query(` - UPDATE project_tasks - SET category_id = (SELECT id FROM task_categories WHERE category_key = ?), - auto_categorized = TRUE, - category_confidence = ? - WHERE id = ? - `, [categorization.category, categorization.confidence, task.id]); - - results.push({ - taskId: task.id, - ...categorization - }); - } - - return results; - } - - /** - * Eksporter statistik pr. kategori - */ - async exportCategoryStatistics(filters = {}) { - const { startDate, endDate, minConfidence = 0.7 } = filters; - - const query = ` - SELECT - tc.category_name, - tc.category_key, - COUNT(pt.id) as task_count, - SUM(pt.estimated_hours) as total_hours, - AVG(pt.estimated_hours) as avg_hours_per_task, - AVG(pt.category_confidence) as avg_confidence, - SUM(pt.actual_cost) as total_cost - FROM task_categories tc - LEFT JOIN project_tasks pt ON pt.category_id = tc.id - WHERE pt.category_confidence >= ? - ${startDate ? 'AND pt.created_at >= ?' : ''} - ${endDate ? 'AND pt.created_at <= ?' : ''} - GROUP BY tc.id, tc.category_name, tc.category_key - ORDER BY total_hours DESC - `; - - const params = [minConfidence]; - if (startDate) params.push(startDate); - if (endDate) params.push(endDate); - - return await db.query(query, params); - } -} - -module.exports = new TaskCategorizationService(); -``` - -#### API Endpoints -```javascript -// Kategoriser en enkelt opgave -app.post('/api/tasks/:taskId/categorize', async (req, res) => { - const { taskId } = req.params; - const task = await db.query('SELECT * FROM project_tasks WHERE id = ?', [taskId]); - - const result = taskCategorizationService.categorizeTask(task[0].task_description); - - await db.query(` - UPDATE project_tasks - SET category_id = (SELECT id FROM task_categories WHERE category_key = ?), - auto_categorized = TRUE, - category_confidence = ? - WHERE id = ? - `, [result.category, result.confidence, taskId]); - - res.json({ success: true, categorization: result }); -}); - -// Kategoriser alle opgaver i et projekt -app.post('/api/projects/:projectId/categorize-tasks', async (req, res) => { - const { projectId } = req.params; - const results = await taskCategorizationService.categorizeProjectTasks(projectId); - res.json({ success: true, results }); -}); - -// Eksporter kategori-statistik -app.get('/api/statistics/categories', async (req, res) => { - const { startDate, endDate, minConfidence } = req.query; - const stats = await taskCategorizationService.exportCategoryStatistics({ - startDate, endDate, minConfidence: parseFloat(minConfidence) - }); - res.json({ success: true, statistics: stats }); -}); - -// Hent alle kategorier -app.get('/api/categories', async (req, res) => { - const categories = await db.query('SELECT * FROM task_categories ORDER BY sort_order'); - res.json({ success: true, categories }); -}); -``` - -### 📊 Seed Data -```sql --- Indsæt standard kategorier -INSERT INTO task_categories (category_name, category_key, icon, color_code, sort_order) VALUES -('Vinduer', 'vinduer', '🪟', '#3b82f6', 1), -('Døre', 'døre', '🚪', '#10b981', 2), -('Gulve', 'gulve', '🏠', '#f59e0b', 3), -('Tage', 'tage', '🏘️', '#ef4444', 4), -('Vægge', 'vægge', '🧱', '#8b5cf6', 5), -('El-arbejde', 'el', '⚡', '#eab308', 6), -('VVS', 'vvs', '🚰', '#06b6d4', 7), -('Andet', 'andre', '🔧', '#6b7280', 99); - --- Indsæt keywords -INSERT INTO category_keywords (category_id, keyword, weight) VALUES --- Vinduer -(1, 'vindu', 1.0), (1, 'vinduer', 1.0), (1, 'termoruder', 1.0), -(1, 'karm', 0.8), (1, 'karme', 0.8), (1, 'ruder', 0.7), --- Døre -(2, 'dør', 1.0), (2, 'døre', 1.0), (2, 'entre', 0.9), -(2, 'entredør', 1.0), (2, 'indgang', 0.7), (2, 'udgang', 0.7), --- Gulve -(3, 'gulv', 1.0), (3, 'gulve', 1.0), (3, 'trægulv', 1.0), -(3, 'laminat', 1.0), (3, 'parket', 1.0), (3, 'vinyl', 0.9), --- Tage -(4, 'tag', 1.0), (4, 'tage', 1.0), (4, 'tagplader', 1.0), -(4, 'spær', 1.0), (4, 'lægter', 1.0), (4, 'undertag', 1.0), --- VVS -(7, 'vvs', 1.0), (7, 'rør', 0.8), (7, 'badeværelse', 0.7), -(7, 'køkken', 0.6), (7, 'vandhane', 0.9), (7, 'afløb', 0.9); -``` - -### 🧪 Tests -```javascript -// test_task_categorization.js - -const service = require('./backend/src/services/taskCategorizationService'); - -console.log('=== Testing Task Categorization ===\n'); - -const testCases = [ - { desc: 'Montering af nye vinduer i stue', expected: 'vinduer' }, - { desc: 'Udskiftning af entredør med sikkerhedslås', expected: 'døre' }, - { desc: 'Lægning af trægulv i alle værelser', expected: 'gulve' }, - { desc: 'Renovering af tag med nye tagplader og spær', expected: 'tage' }, - { desc: 'Installation af stikkontakter og belysning', expected: 'el' }, - { desc: 'Montering af ny vandhane i køkken', expected: 'vvs' } -]; - -for (const test of testCases) { - const result = service.categorizeTask(test.desc); - const status = result.category === test.expected ? '✅' : '❌'; - console.log(`${status} "${test.desc}"`); - console.log(` → ${result.category} (${(result.confidence * 100).toFixed(0)}% confidence)`); - console.log(` → Keywords: ${result.matchedKeywords.map(k => k.keyword).join(', ')}\n`); -} -``` - ---- - -## 2️⃣ Geometri-beregning af Tag - -### 📝 Beskrivelse -Geometrimodulet skal håndtere 'stern til kip' korrekt og tillade ændring af grader og spærafstand. - -### 🎯 Acceptance Criteria -- [x] Feltnavn ændres til korrekt begreb (ryghøjde) -- [x] Gradfelt kan ændres efter beregning uden fejl -- [x] Spærafstand kan tilpasses (0,6m / 1m / variabel) - -### 🔧 Teknisk Implementation - -#### Database Schema Update -```sql --- Opdater roof_geometry tabel -ALTER TABLE roof_geometry - -- Omdøb wall_height til ridge_to_stern_height (stern til kip) - CHANGE COLUMN wall_height stern_to_ridge_height DECIMAL(10,2) COMMENT 'Højde fra stern til kip', - - -- Tilføj nye felter - ADD COLUMN roof_angle_degrees DECIMAL(5,2) AFTER roof_pitch COMMENT 'Taghældning i grader', - ADD COLUMN spaer_distance DECIMAL(5,2) DEFAULT 0.6 COMMENT 'Spærafstand i meter (0.6m, 1m, custom)', - ADD COLUMN spaer_count INT COMMENT 'Antal spær beregnet', - ADD COLUMN allow_angle_edit BOOLEAN DEFAULT TRUE COMMENT 'Tillad ændring af grader efter beregning', - ADD COLUMN calculation_method ENUM('pitch', 'angle', 'manual') DEFAULT 'pitch' COMMENT 'Beregningsmetode'; - --- Tilføj index for hurtigere queries -CREATE INDEX idx_roof_angle ON roof_geometry(roof_angle_degrees); -CREATE INDEX idx_spaer_distance ON roof_geometry(spaer_distance); -``` - -#### Frontend Component Update -```jsx -// frontend/src/components/GeometryInput.js - -function RoofGeometryInput({ projectId, onComplete }) { - const [geometry, setGeometry] = useState({ - width: '', - length: '', - sternToRidgeHeight: '', // Renamed from wallHeight - roofAngleDegrees: '', // New field - roofPitch: '', - spaerDistance: 0.6, // Default 0.6m - calculationMethod: 'angle' // 'pitch', 'angle', or 'manual' - }); - - const [calculatedValues, setCalculatedValues] = useState(null); - const [allowEdit, setAllowEdit] = useState(true); - - // Beregn taghældning fra grader eller omvendt - const handleAngleChange = (angleDegrees) => { - const angleRad = (angleDegrees * Math.PI) / 180; - const pitch = Math.tan(angleRad); - - setGeometry(prev => ({ - ...prev, - roofAngleDegrees: angleDegrees, - roofPitch: pitch.toFixed(4), - calculationMethod: 'angle' - })); - - recalculateGeometry({ ...geometry, roofAngleDegrees: angleDegrees }); - }; - - const handlePitchChange = (pitch) => { - const angleDegrees = (Math.atan(pitch) * 180) / Math.PI; - - setGeometry(prev => ({ - ...prev, - roofPitch: pitch, - roofAngleDegrees: angleDegrees.toFixed(2), - calculationMethod: 'pitch' - })); - - recalculateGeometry({ ...geometry, roofPitch: pitch }); - }; - - const recalculateGeometry = (updatedGeometry) => { - if (!updatedGeometry.width || !updatedGeometry.length || !updatedGeometry.sternToRidgeHeight) { - return; - } - - const width = parseFloat(updatedGeometry.width); - const length = parseFloat(updatedGeometry.length); - const ridgeHeight = parseFloat(updatedGeometry.sternToRidgeHeight); - const angle = parseFloat(updatedGeometry.roofAngleDegrees); - const spaerDist = parseFloat(updatedGeometry.spaerDistance); - - // Beregn tagets geometri - const halfWidth = width / 2; - const roofSlope = Math.sqrt(Math.pow(halfWidth, 2) + Math.pow(ridgeHeight, 2)); - const roofArea = 2 * roofSlope * length; - - // Beregn antal spær - const spaerCount = Math.ceil(length / spaerDist) + 1; - - // Beregn vindskede (diagonal) - const vindskede = Math.sqrt(Math.pow(roofSlope, 2) + Math.pow(length, 2)); - - setCalculatedValues({ - roofArea: roofArea.toFixed(2), - roofSlope: roofSlope.toFixed(2), - spaerCount, - totalSpaerLength: (spaerCount * roofSlope * 2).toFixed(2), - vindskede: vindskede.toFixed(2), - actualSpaerDistance: (length / (spaerCount - 1)).toFixed(2) - }); - }; - - const handleSubmit = async () => { - const payload = { - roofWidth: geometry.width, - roofLength: geometry.length, - sternToRidgeHeight: geometry.sternToRidgeHeight, - roofAngleDegrees: geometry.roofAngleDegrees, - roofPitch: geometry.roofPitch, - spaerDistance: geometry.spaerDistance, - calculationMethod: geometry.calculationMethod, - allowAngleEdit: allowEdit - }; - - const response = await fetch(`/api/customer-projects/${projectId}/geometry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const result = await response.json(); - if (result.success) { - onComplete(result); - } - }; - - return ( -
-

Tag Geometri

- - {/* Basic dimensions */} -
- - { - setGeometry({...geometry, width: e.target.value}); - recalculateGeometry({...geometry, width: e.target.value}); - }} - /> -
- -
- - { - setGeometry({...geometry, length: e.target.value}); - recalculateGeometry({...geometry, length: e.target.value}); - }} - /> -
- -
- - { - setGeometry({...geometry, sternToRidgeHeight: e.target.value}); - recalculateGeometry({...geometry, sternToRidgeHeight: e.target.value}); - }} - /> -
- - {/* Angle/Pitch input */} -
- - handleAngleChange(parseFloat(e.target.value))} - disabled={!allowEdit && calculatedValues} - /> - {!allowEdit && ( - - )} -
- - {/* Spærafstand selector */} -
- -
- - - { - setGeometry({...geometry, spaerDistance: e.target.value}); - recalculateGeometry({...geometry, spaerDistance: e.target.value}); - }} - /> -
-
- - {/* Calculated values */} - {calculatedValues && ( -
-

Beregnede værdier:

-
-
📐 Tagareal: {calculatedValues.roofArea} m²
-
📏 Taghældning: {calculatedValues.roofSlope} m
-
🪚 Antal spær: {calculatedValues.spaerCount} stk
-
📏 Faktisk afstand: {calculatedValues.actualSpaerDistance} m
-
🔗 Total spærlængde: {calculatedValues.totalSpaerLength} m
-
📐 Vindskede: {calculatedValues.vindskede} lbm
-
-
- )} - - -
- ); -} -``` - -#### Backend API Update -```javascript -// unified-server.js - Update geometry endpoint - -app.post('/api/customer-projects/:projectId/geometry', async (req, res) => { - try { - const { projectId } = req.params; - const { - roofWidth, - roofLength, - sternToRidgeHeight, // Renamed from wallHeight - roofAngleDegrees, // New field - roofPitch, - roofType, - complexity, - spaerDistance = 0.6, // New field with default - calculationMethod = 'angle', - allowAngleEdit = true - } = req.body; - - // Validation - if (!roofWidth || !roofLength || !sternToRidgeHeight) { - return res.status(400).json({ - success: false, - error: 'Tag bredde, længde og højde stern til kip er påkrævet' - }); - } - - // Calculate geometry - const width = parseFloat(roofWidth); - const length = parseFloat(roofLength); - const ridgeHeight = parseFloat(sternToRidgeHeight); - const angle = parseFloat(roofAngleDegrees); - const spaerDist = parseFloat(spaerDistance); - - // Calculate roof area using Pythagoras - const halfWidth = width / 2; - const roofSlope = Math.sqrt(Math.pow(halfWidth, 2) + Math.pow(ridgeHeight, 2)); - const totalArea = 2 * roofSlope * length; - - // Calculate spær count - const spaerCount = Math.ceil(length / spaerDist) + 1; - const totalSpaerLength = spaerCount * roofSlope * 2; - - // Calculate vindskede - const vindskede = Math.sqrt(Math.pow(roofSlope, 2) + Math.pow(length, 2)); - - // Save to database - await databaseService.query(` - INSERT INTO roof_geometry ( - project_id, width_main, length_main, total_area, - stern_to_ridge_height, roof_angle_degrees, roof_pitch, - roof_type, complexity_factor, roof_height, vindskede_lbm, - spaer_distance, spaer_count, allow_angle_edit, calculation_method, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) - ON DUPLICATE KEY UPDATE - width_main = VALUES(width_main), - length_main = VALUES(length_main), - total_area = VALUES(total_area), - stern_to_ridge_height = VALUES(stern_to_ridge_height), - roof_angle_degrees = VALUES(roof_angle_degrees), - roof_pitch = VALUES(roof_pitch), - spaer_distance = VALUES(spaer_distance), - spaer_count = VALUES(spaer_count), - allow_angle_edit = VALUES(allow_angle_edit), - calculation_method = VALUES(calculation_method), - updated_at = NOW() - `, [ - projectId, width, length, totalArea, - ridgeHeight, angle, roofPitch, - roofType, complexity, roofSlope, vindskede, - spaerDist, spaerCount, allowAngleEdit, calculationMethod - ]); - - res.json({ - success: true, - geometry: { - area: totalArea.toFixed(2), - roofSlope: roofSlope.toFixed(2), - spaerCount, - totalSpaerLength: totalSpaerLength.toFixed(2), - vindskede: vindskede.toFixed(2), - actualSpaerDistance: (length / (spaerCount - 1)).toFixed(2) - } - }); - - } catch (error) { - console.error('Geometry calculation error:', error); - res.status(500).json({ success: false, error: error.message }); - } -}); -``` - ---- - -## 3️⃣ Smart Pakker Udvidelse - -### 📝 Beskrivelse -Smart pakker skal indeholde materialer, tidsforbrug og opgaver i korrekt rækkefølge. - -### 🎯 Acceptance Criteria -- [x] Pakker gemmes med defineret rækkefølge af opgaver -- [x] Materialer og tid kobles automatisk til geometridata -- [x] Final review viser samlet tids- og materialeberegning - -### 🔧 Teknisk Implementation - -#### Database Schema -```sql --- Tilføj task ordering til smart packages -ALTER TABLE package_tasks - ADD COLUMN task_order INT DEFAULT 0 COMMENT 'Rækkefølge i pakken', - ADD COLUMN task_phase ENUM('forberedelse', 'hovedarbejde', 'finishing') DEFAULT 'hovedarbejde', - ADD COLUMN depends_on_task_id INT COMMENT 'Opgave der skal være færdig først', - ADD FOREIGN KEY (depends_on_task_id) REFERENCES package_tasks(id); - --- Tilføj geometri-kobling til materialer -ALTER TABLE package_materials - ADD COLUMN geometry_multiplier VARCHAR(50) COMMENT 'Formel: area, length, width, spaer_count', - ADD COLUMN base_quantity DECIMAL(10,2) COMMENT 'Basisantal før multiplikation', - ADD COLUMN waste_factor DECIMAL(5,2) DEFAULT 1.1 COMMENT 'Spild-faktor (1.1 = 10% spild)'; - -CREATE INDEX idx_task_order ON package_tasks(smart_package_id, task_order); -``` - -#### Service Implementation -```javascript -// backend/src/services/smartPackageService.js - -class SmartPackageService { - - /** - * Hent smart pakke med opgaver i korrekt rækkefølge - */ - async getPackageWithOrderedTasks(packageId) { - const packageData = await db.query( - 'SELECT * FROM smart_packages WHERE id = ?', - [packageId] - ); - - const tasks = await db.query(` - SELECT - pt.*, - pt.task_order, - pt.task_phase, - pt.depends_on_task_id, - dt.task_name as depends_on_task_name - FROM package_tasks pt - LEFT JOIN package_tasks dt ON pt.depends_on_task_id = dt.id - WHERE pt.smart_package_id = ? - ORDER BY pt.task_phase, pt.task_order ASC - `, [packageId]); - - const materials = await db.query(` - SELECT - pm.*, - pm.geometry_multiplier, - pm.base_quantity, - pm.waste_factor - FROM package_materials pm - WHERE pm.smart_package_id = ? - ORDER BY pm.material_category, pm.material_name - `, [packageId]); - - return { - package: packageData[0], - tasks, - materials - }; - } - - /** - * Beregn materialer baseret på geometri - */ - calculateMaterialsForGeometry(materials, geometry) { - return materials.map(material => { - let quantity = material.base_quantity || 0; - - // Apply geometry multiplier - switch (material.geometry_multiplier) { - case 'area': - quantity = geometry.total_area * material.base_quantity; - break; - case 'length': - quantity = geometry.length_main * material.base_quantity; - break; - case 'width': - quantity = geometry.width_main * material.base_quantity; - break; - case 'spaer_count': - quantity = geometry.spaer_count * material.base_quantity; - break; - case 'perimeter': - quantity = (2 * geometry.width_main + 2 * geometry.length_main) * material.base_quantity; - break; - } - - // Apply waste factor - const finalQuantity = quantity * (material.waste_factor || 1.1); - - return { - ...material, - calculated_quantity: Math.ceil(finalQuantity), - base_calculation: quantity, - waste_amount: finalQuantity - quantity - }; - }); - } - - /** - * Generer final review data - */ - async generateFinalReview(projectId) { - // Get project data - const project = await db.query( - 'SELECT * FROM customer_projects WHERE id = ?', - [projectId] - ); - - // Get geometry - const geometry = await db.query( - 'SELECT * FROM roof_geometry WHERE project_id = ?', - [projectId] - ); - - // Get selected packages - const packages = await db.query(` - SELECT sp.*, cpp.quantity - FROM smart_packages sp - JOIN customer_project_packages cpp ON sp.id = cpp.smart_package_id - WHERE cpp.project_id = ? - `, [projectId]); - - const packageDetails = []; - let totalHours = 0; - let totalMaterialCost = 0; - - for (const pkg of packages) { - const details = await this.getPackageWithOrderedTasks(pkg.id); - const materials = this.calculateMaterialsForGeometry( - details.materials, - geometry[0] - ); - - // Calculate costs - const packageHours = details.tasks.reduce((sum, task) => - sum + (task.estimated_hours || 0), 0 - ); - const packageMaterialCost = materials.reduce((sum, mat) => - sum + (mat.calculated_quantity * mat.unit_price || 0), 0 - ); - - totalHours += packageHours; - totalMaterialCost += packageMaterialCost; - - packageDetails.push({ - package: pkg, - tasks: details.tasks, - materials, - summary: { - hours: packageHours, - laborCost: packageHours * 1626, // Default hourly rate - materialCost: packageMaterialCost, - totalCost: (packageHours * 1626) + packageMaterialCost - } - }); - } - - return { - project: project[0], - geometry: geometry[0], - packages: packageDetails, - totals: { - hours: totalHours, - laborCost: totalHours * 1626, - materialCost: totalMaterialCost, - totalCost: (totalHours * 1626) + totalMaterialCost, - vat: ((totalHours * 1626) + totalMaterialCost) * 0.25 - } - }; - } -} - -module.exports = new SmartPackageService(); -``` - ---- - -## 4️⃣ AI-baseret Kundebeskrivelse - -### 📝 Beskrivelse -Generér en kundevenlig tekst ud fra montagevejledningen og indsæt i tilbudsbeskrivelsen. - -### 🎯 Acceptance Criteria -- [x] Beskrivelse er letforståelig og kundevenlig -- [x] Teknisk montagevejledning gemmes separat i opgavefeltet -- [x] Begge dele overføres korrekt til Ordrestyring - -### 🔧 Teknisk Implementation - -#### Database Schema -```sql --- Tilføj felter til beskrivelser -ALTER TABLE package_tasks - ADD COLUMN technical_description TEXT COMMENT 'Teknisk montagevejledning', - ADD COLUMN customer_friendly_description TEXT COMMENT 'Kundevenlig beskrivelse', - ADD COLUMN ai_generated BOOLEAN DEFAULT FALSE, - ADD COLUMN ai_generation_timestamp TIMESTAMP NULL; - -ALTER TABLE customer_projects - ADD COLUMN customer_description TEXT COMMENT 'Samlet kundevenlig beskrivelse', - ADD COLUMN technical_notes TEXT COMMENT 'Tekniske noter til montører'; -``` - -#### AI Service -```javascript -// backend/src/services/aiDescriptionService.js - -const OpenAI = require('openai'); - -class AIDescriptionService { - - constructor() { - this.openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY - }); - } - - /** - * Generér kundevenlig beskrivelse fra teknisk tekst - */ - async generateCustomerFriendlyDescription(technicalDescription, context = {}) { - const prompt = ` -Du er en ekspert i at kommunikere byggeprojekter til kunder på en letforståelig måde. - -TEKNISK BESKRIVELSE: -${technicalDescription} - -KONTEKST: -- Projekt type: ${context.projectType || 'Byggeprojekt'} -- Område: ${context.area || 'Ikke specificeret'} -- Kunde niveau: ${context.customerLevel || 'Standard'} - -OPGAVE: -Omskriv den tekniske beskrivelse til en kundevenlig tekst der: -1. Er let at forstå for en person uden byggefaglig baggrund -2. Fokuserer på fordele og resultatet for kunden -3. Undgår teknisk jargon (eller forklarer det simpelt) -4. Er positiv og professionel i tonen -5. Er max 3-4 sætninger - -KUNDEVENLIG BESKRIVELSE: - `.trim(); - - try { - const completion = await this.openai.chat.completions.create({ - model: "gpt-4", - messages: [ - { - role: "system", - content: "Du er en professionel kommunikationsekspert inden for byggeri. Du omskriver tekniske beskrivelser til kundevenlige tekster." - }, - { - role: "user", - content: prompt - } - ], - temperature: 0.7, - max_tokens: 300 - }); - - return completion.choices[0].message.content.trim(); - } catch (error) { - console.error('AI generation error:', error); - throw new Error('Kunne ikke generere kundevenlig beskrivelse'); - } - } - - /** - * Generér beskrivelse for alle opgaver i en pakke - */ - async generateDescriptionsForPackage(packageId) { - const tasks = await db.query( - 'SELECT * FROM package_tasks WHERE smart_package_id = ?', - [packageId] - ); - - const results = []; - - for (const task of tasks) { - if (!task.technical_description) { - console.log(`⚠️ No technical description for task ${task.id}, skipping`); - continue; - } - - const customerDescription = await this.generateCustomerFriendlyDescription( - task.technical_description, - { - projectType: 'Tag udskiftning', - area: 'Tag' - } - ); - - await db.query(` - UPDATE package_tasks - SET customer_friendly_description = ?, - ai_generated = TRUE, - ai_generation_timestamp = NOW() - WHERE id = ? - `, [customerDescription, task.id]); - - results.push({ - taskId: task.id, - taskName: task.task_name, - technical: task.technical_description, - customerFriendly: customerDescription - }); - } - - return results; - } - - /** - * Generér samlet projektbeskrivelse - */ - async generateProjectDescription(projectId) { - const tasks = await db.query(` - SELECT - pt.customer_friendly_description, - pt.task_name, - pt.task_order, - sp.package_name - FROM package_tasks pt - JOIN customer_project_packages cpp ON pt.smart_package_id = cpp.smart_package_id - JOIN smart_packages sp ON sp.id = cpp.smart_package_id - WHERE cpp.project_id = ? - ORDER BY pt.task_order - `, [projectId]); - - // Gruppér efter pakke - const packageGroups = {}; - for (const task of tasks) { - if (!packageGroups[task.package_name]) { - packageGroups[task.package_name] = []; - } - packageGroups[task.package_name].push(task); - } - - // Byg samlet beskrivelse - let description = '## Hvad indgår i projektet?\n\n'; - - for (const [packageName, packageTasks] of Object.entries(packageGroups)) { - description += `### ${packageName}\n`; - for (const task of packageTasks) { - if (task.customer_friendly_description) { - description += `- ${task.customer_friendly_description}\n`; - } - } - description += '\n'; - } - - await db.query( - 'UPDATE customer_projects SET customer_description = ? WHERE id = ?', - [description, projectId] - ); - - return description; - } - - /** - * Sync til Ordrestyring med begge beskrivelser - */ - async syncToOrdrestyring(projectId) { - const project = await db.query( - 'SELECT * FROM customer_projects WHERE id = ?', - [projectId] - ); - - const tasks = await db.query(` - SELECT - pt.task_name, - pt.technical_description, - pt.customer_friendly_description, - pt.estimated_hours - FROM package_tasks pt - JOIN customer_project_packages cpp ON pt.smart_package_id = cpp.smart_package_id - WHERE cpp.project_id = ? - `, [projectId]); - - // Format til Ordrestyring - const ordrestyringPayload = { - project_name: project[0].project_name, - customer_description: project[0].customer_description, - tasks: tasks.map(task => ({ - name: task.task_name, - description: task.customer_friendly_description, // Til kunden - technical_notes: task.technical_description, // Til montører - estimated_hours: task.estimated_hours - })) - }; - - // Send til Ordrestyring API - const response = await fetch(`${process.env.ORDRESTYRING_API_URL}/projects`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.ORDRESTYRING_API_TOKEN}` - }, - body: JSON.stringify(ordrestyringPayload) - }); - - return await response.json(); - } -} - -module.exports = new AIDescriptionService(); -``` - -#### API Endpoints -```javascript -// Generér kundevenlig beskrivelse for en opgave -app.post('/api/tasks/:taskId/generate-description', async (req, res) => { - const { taskId } = req.params; - const task = await db.query('SELECT * FROM package_tasks WHERE id = ?', [taskId]); - - const description = await aiDescriptionService.generateCustomerFriendlyDescription( - task[0].technical_description - ); - - await db.query( - 'UPDATE package_tasks SET customer_friendly_description = ?, ai_generated = TRUE WHERE id = ?', - [description, taskId] - ); - - res.json({ success: true, description }); -}); - -// Generér beskrivelser for hele pakken -app.post('/api/packages/:packageId/generate-descriptions', async (req, res) => { - const { packageId } = req.params; - const results = await aiDescriptionService.generateDescriptionsForPackage(packageId); - res.json({ success: true, results }); -}); - -// Generér samlet projektbeskrivelse -app.post('/api/projects/:projectId/generate-description', async (req, res) => { - const { projectId } = req.params; - const description = await aiDescriptionService.generateProjectDescription(projectId); - res.json({ success: true, description }); -}); - -// Sync til Ordrestyring -app.post('/api/projects/:projectId/sync-to-ordrestyring', async (req, res) => { - const { projectId } = req.params; - const result = await aiDescriptionService.syncToOrdrestyring(projectId); - res.json({ success: true, result }); -}); -``` - -#### Frontend Component -```jsx -// frontend/src/components/AIDescriptionGenerator.js - -function AIDescriptionGenerator({ taskId, technicalDescription, onGenerated }) { - const [loading, setLoading] = useState(false); - const [customerDescription, setCustomerDescription] = useState(''); - - const generateDescription = async () => { - setLoading(true); - try { - const response = await fetch(`/api/tasks/${taskId}/generate-description`, { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - setCustomerDescription(result.description); - onGenerated(result.description); - } - } catch (error) { - console.error('Error generating description:', error); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

Teknisk beskrivelse (til montører):

-

{technicalDescription}

-
- - - - {customerDescription && ( -
-

Kundevenlig beskrivelse:

-

{customerDescription}

-