61 KiB
61 KiB
🗺️ 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
- System genkender nøgleord i beskrivelser
- Beskrivelser mappe til standardiserede kategorier
- Kan eksportere statistik pr. kategori
🔧 Teknisk Implementation
Database Schema
-- 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
// 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
// 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
-- 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
// 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
- Feltnavn ændres til korrekt begreb (ryghøjde)
- Gradfelt kan ændres efter beregning uden fejl
- Spærafstand kan tilpasses (0,6m / 1m / variabel)
🔧 Teknisk Implementation
Database Schema Update
-- 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
// 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 (
<div className="roof-geometry-input">
<h3>Tag Geometri</h3>
{/* Basic dimensions */}
<div className="input-group">
<label>Tagbredde (m):</label>
<input
type="number"
value={geometry.width}
onChange={(e) => {
setGeometry({...geometry, width: e.target.value});
recalculateGeometry({...geometry, width: e.target.value});
}}
/>
</div>
<div className="input-group">
<label>Taglængde (m):</label>
<input
type="number"
value={geometry.length}
onChange={(e) => {
setGeometry({...geometry, length: e.target.value});
recalculateGeometry({...geometry, length: e.target.value});
}}
/>
</div>
<div className="input-group">
<label>Højde stern til kip (m):</label>
<input
type="number"
step="0.1"
value={geometry.sternToRidgeHeight}
onChange={(e) => {
setGeometry({...geometry, sternToRidgeHeight: e.target.value});
recalculateGeometry({...geometry, sternToRidgeHeight: e.target.value});
}}
/>
</div>
{/* Angle/Pitch input */}
<div className="input-group">
<label>Taghældning (grader):</label>
<input
type="number"
step="0.5"
value={geometry.roofAngleDegrees}
onChange={(e) => handleAngleChange(parseFloat(e.target.value))}
disabled={!allowEdit && calculatedValues}
/>
{!allowEdit && (
<button onClick={() => setAllowEdit(true)}>Rediger hældning</button>
)}
</div>
{/* Spærafstand selector */}
<div className="input-group">
<label>Spærafstand:</label>
<div className="spaer-distance-selector">
<button
className={geometry.spaerDistance === 0.6 ? 'active' : ''}
onClick={() => {
setGeometry({...geometry, spaerDistance: 0.6});
recalculateGeometry({...geometry, spaerDistance: 0.6});
}}
>
0,6m (standard)
</button>
<button
className={geometry.spaerDistance === 1.0 ? 'active' : ''}
onClick={() => {
setGeometry({...geometry, spaerDistance: 1.0});
recalculateGeometry({...geometry, spaerDistance: 1.0});
}}
>
1,0m
</button>
<input
type="number"
step="0.1"
placeholder="Variabel"
value={geometry.spaerDistance}
onChange={(e) => {
setGeometry({...geometry, spaerDistance: e.target.value});
recalculateGeometry({...geometry, spaerDistance: e.target.value});
}}
/>
</div>
</div>
{/* Calculated values */}
{calculatedValues && (
<div className="calculated-results">
<h4>Beregnede værdier:</h4>
<div className="result-grid">
<div>📐 Tagareal: <strong>{calculatedValues.roofArea} m²</strong></div>
<div>📏 Taghældning: <strong>{calculatedValues.roofSlope} m</strong></div>
<div>🪚 Antal spær: <strong>{calculatedValues.spaerCount} stk</strong></div>
<div>📏 Faktisk afstand: <strong>{calculatedValues.actualSpaerDistance} m</strong></div>
<div>🔗 Total spærlængde: <strong>{calculatedValues.totalSpaerLength} m</strong></div>
<div>📐 Vindskede: <strong>{calculatedValues.vindskede} lbm</strong></div>
</div>
</div>
)}
<button onClick={handleSubmit} className="submit-btn">
Gem geometri
</button>
</div>
);
}
Backend API Update
// 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
- Pakker gemmes med defineret rækkefølge af opgaver
- Materialer og tid kobles automatisk til geometridata
- Final review viser samlet tids- og materialeberegning
🔧 Teknisk Implementation
Database Schema
-- 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
// 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
- Beskrivelse er letforståelig og kundevenlig
- Teknisk montagevejledning gemmes separat i opgavefeltet
- Begge dele overføres korrekt til Ordrestyring
🔧 Teknisk Implementation
Database Schema
-- 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
// 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
// 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
// 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 (
<div className="ai-description-generator">
<div className="technical-desc">
<h4>Teknisk beskrivelse (til montører):</h4>
<p>{technicalDescription}</p>
</div>
<button onClick={generateDescription} disabled={loading}>
{loading ? '🤖 Genererer...' : '✨ Generér kundevenlig beskrivelse'}
</button>
{customerDescription && (
<div className="customer-desc">
<h4>Kundevenlig beskrivelse:</h4>
<p>{customerDescription}</p>
<textarea
value={customerDescription}
onChange={(e) => setCustomerDescription(e.target.value)}
placeholder="Rediger beskrivelsen her..."
/>
</div>
)}
</div>
);
}
5️⃣ PDF-preview i Final Review
📝 Beskrivelse
Systemet skal tilbyde en PDF-forhåndsvisning af tilbuddet med logo og formatering.
🎯 Acceptance Criteria
- Knap i final review åbner en PDF-preview
- PDF indeholder alle relevante detaljer og firmaets logo
- PDF'en kan gemmes eller sendes videre
🔧 Teknisk Implementation
Install Dependencies
npm install puppeteer pdfkit --save
PDF Generation Service
// backend/src/services/pdfGenerationService.js
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
const path = require('path');
class PDFGenerationService {
/**
* Generér PDF tilbud
*/
async generateQuotePDF(projectId) {
// Get all data
const project = await db.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
const geometry = await db.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
const packages = await db.query(`
SELECT
sp.*,
cpp.quantity,
cpp.custom_price
FROM smart_packages sp
JOIN customer_project_packages cpp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
const tasks = await db.query(`
SELECT
pt.task_name,
pt.customer_friendly_description,
pt.estimated_hours,
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]);
const materials = await db.query(`
SELECT
pm.material_name,
pm.calculated_quantity,
pm.unit_price,
pm.unit,
sp.package_name
FROM package_materials pm
JOIN customer_project_packages cpp ON pm.smart_package_id = cpp.smart_package_id
JOIN smart_packages sp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
// Calculate totals
const totalHours = tasks.reduce((sum, t) => sum + t.estimated_hours, 0);
const laborCost = totalHours * 1626;
const materialCost = materials.reduce((sum, m) =>
sum + (m.calculated_quantity * m.unit_price), 0
);
const subtotal = laborCost + materialCost;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Generate HTML
const html = this.generateQuoteHTML({
project: project[0],
geometry: geometry[0],
packages,
tasks,
materials,
totals: {
hours: totalHours,
laborCost,
materialCost,
subtotal,
vat,
total
}
});
// Generate PDF from HTML
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
},
printBackground: true
});
await browser.close();
// Save PDF
const filename = `tilbud_${projectId}_${Date.now()}.pdf`;
const filepath = path.join(__dirname, '../../public/quotes', filename);
await fs.writeFile(filepath, pdfBuffer);
// Save reference in database
await db.query(
'UPDATE customer_projects SET quote_pdf_path = ? WHERE id = ?',
[filename, projectId]
);
return {
filename,
filepath,
url: `/quotes/${filename}`
};
}
/**
* Generér HTML til PDF
*/
generateQuoteHTML(data) {
const { project, geometry, packages, tasks, materials, totals } = data;
const today = new Date().toLocaleDateString('da-DK');
return `
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<title>Tilbud - ${project.project_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #333;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
border-bottom: 3px solid #f59e0b;
padding-bottom: 20px;
}
.logo {
width: 150px;
height: auto;
}
.company-info {
text-align: right;
font-size: 9pt;
}
h1 {
color: #1e40af;
font-size: 24pt;
margin-bottom: 10px;
}
h2 {
color: #1e40af;
font-size: 16pt;
margin: 20px 0 10px 0;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 5px;
}
h3 {
color: #374151;
font-size: 13pt;
margin: 15px 0 8px 0;
}
.customer-info, .project-info {
margin-bottom: 20px;
background: #f9fafb;
padding: 15px;
border-radius: 5px;
}
.info-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
}
.info-label {
font-weight: bold;
color: #6b7280;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
th {
background: #1e40af;
color: white;
padding: 10px;
text-align: left;
font-weight: bold;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #e5e7eb;
}
tr:nth-child(even) {
background: #f9fafb;
}
.totals-table {
margin-top: 20px;
width: 400px;
margin-left: auto;
}
.totals-table td {
padding: 8px;
}
.totals-table .total-row {
background: #fef3c7;
font-weight: bold;
font-size: 12pt;
border-top: 2px solid #f59e0b;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 2px solid #e5e7eb;
font-size: 9pt;
color: #6b7280;
text-align: center;
}
.section {
margin-bottom: 30px;
}
</style>
</head>
<body>
<div class="header">
<div>
<img src="/logo.png" alt="Firma Logo" class="logo" />
</div>
<div class="company-info">
<strong>Dit Firma ApS</strong><br>
Firmaadresse 123<br>
8000 Aarhus C<br>
CVR: 12345678<br>
Tlf: +45 12 34 56 78<br>
Email: info@ditfirma.dk
</div>
</div>
<h1>Tilbud</h1>
<p style="margin-bottom: 20px;">Dato: ${today}</p>
<div class="section customer-info">
<h2>Kundeoplysninger</h2>
<div class="info-grid">
<span class="info-label">Navn:</span>
<span>${project.customer_name}</span>
<span class="info-label">Adresse:</span>
<span>${project.customer_address || 'Ikke angivet'}</span>
<span class="info-label">Email:</span>
<span>${project.customer_email || 'Ikke angivet'}</span>
<span class="info-label">Telefon:</span>
<span>${project.customer_phone || 'Ikke angivet'}</span>
</div>
</div>
<div class="section project-info">
<h2>Projektbeskrivelse</h2>
<div class="info-grid">
<span class="info-label">Projekt:</span>
<span>${project.project_name}</span>
<span class="info-label">Type:</span>
<span>Tag udskiftning</span>
${geometry ? `
<span class="info-label">Tagareal:</span>
<span>${geometry.total_area} m²</span>
<span class="info-label">Dimensioner:</span>
<span>${geometry.width_main}m × ${geometry.length_main}m</span>
` : ''}
</div>
${project.customer_description ? `
<div style="margin-top: 15px;">
<p>${project.customer_description.replace(/\n/g, '<br>')}</p>
</div>
` : ''}
</div>
<div class="section">
<h2>Arbejdsopgaver</h2>
<table>
<thead>
<tr>
<th>Opgave</th>
<th>Beskrivelse</th>
<th style="text-align: right;">Timer</th>
</tr>
</thead>
<tbody>
${tasks.map(task => `
<tr>
<td><strong>${task.task_name}</strong></td>
<td>${task.customer_friendly_description || '-'}</td>
<td style="text-align: right;">${task.estimated_hours}h</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="section">
<h2>Materialer</h2>
<table>
<thead>
<tr>
<th>Materiale</th>
<th style="text-align: right;">Antal</th>
<th>Enhed</th>
<th style="text-align: right;">Pris/enhed</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
${materials.map(mat => `
<tr>
<td>${mat.material_name}</td>
<td style="text-align: right;">${mat.calculated_quantity}</td>
<td>${mat.unit}</td>
<td style="text-align: right;">${mat.unit_price.toFixed(2)} kr.</td>
<td style="text-align: right;">${(mat.calculated_quantity * mat.unit_price).toFixed(2)} kr.</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<table class="totals-table">
<tr>
<td>Arbejdsløn (${totals.hours}h á 1.626 kr.):</td>
<td style="text-align: right;">${totals.laborCost.toFixed(2)} kr.</td>
</tr>
<tr>
<td>Materialer:</td>
<td style="text-align: right;">${totals.materialCost.toFixed(2)} kr.</td>
</tr>
<tr>
<td><strong>Subtotal:</strong></td>
<td style="text-align: right;"><strong>${totals.subtotal.toFixed(2)} kr.</strong></td>
</tr>
<tr>
<td>Moms (25%):</td>
<td style="text-align: right;">${totals.vat.toFixed(2)} kr.</td>
</tr>
<tr class="total-row">
<td>TOTAL:</td>
<td style="text-align: right;">${totals.total.toFixed(2)} kr.</td>
</tr>
</table>
<div class="section" style="margin-top: 40px;">
<h2>Betalingsbetingelser</h2>
<p>
Betaling: Netto 30 dage<br>
Tilbuddet er gyldigt i 30 dage fra dato.<br>
Priserne er ekskl. moms, medmindre andet er angivet.
</p>
</div>
<div class="footer">
<p>Med venlig hilsen<br>
<strong>Dit Firma ApS</strong></p>
</div>
</body>
</html>
`;
}
}
module.exports = new PDFGenerationService();
API Endpoint
// Generér PDF tilbud
app.post('/api/projects/:projectId/generate-pdf', async (req, res) => {
try {
const { projectId } = req.params;
const result = await pdfGenerationService.generateQuotePDF(projectId);
res.json({
success: true,
pdfUrl: result.url,
filename: result.filename
});
} catch (error) {
console.error('PDF generation error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Download PDF
app.get('/api/projects/:projectId/download-pdf', async (req, res) => {
const { projectId } = req.params;
const project = await db.query(
'SELECT quote_pdf_path FROM customer_projects WHERE id = ?',
[projectId]
);
if (!project[0] || !project[0].quote_pdf_path) {
return res.status(404).json({ error: 'PDF ikke fundet' });
}
const filepath = path.join(__dirname, 'public/quotes', project[0].quote_pdf_path);
res.download(filepath);
});
Frontend Component
// frontend/src/components/PDFPreview.js
function PDFPreview({ projectId }) {
const [pdfUrl, setPdfUrl] = useState(null);
const [loading, setLoading] = useState(false);
const generatePDF = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${projectId}/generate-pdf`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
setPdfUrl(result.pdfUrl);
}
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setLoading(false);
}
};
return (
<div className="pdf-preview">
<button onClick={generatePDF} disabled={loading}>
{loading ? '📄 Genererer PDF...' : '📄 Generer PDF forhåndsvisning'}
</button>
{pdfUrl && (
<div className="pdf-viewer">
<iframe
src={pdfUrl}
width="100%"
height="800px"
title="PDF Preview"
/>
<div className="pdf-actions">
<a href={pdfUrl} download className="btn">
💾 Download PDF
</a>
<button onClick={() => window.open(pdfUrl, '_blank')}>
🔗 Åbn i nyt vindue
</button>
</div>
</div>
)}
</div>
);
}
6️⃣ Web-scraper til Montagevejledninger
📝 Beskrivelse
Systemet skal kunne logge ind på Bygma og hente montagevejledninger (PDF) automatisk.
🎯 Acceptance Criteria
- Web-scraper logger ind med gyldige brugeroplysninger
- Henter PDF-links til montagevejledninger
- Gemmer PDF'er lokalt til brug i smartpakker
🔧 Teknisk Implementation
Install Dependencies
npm install puppeteer axios cheerio --save
Web Scraper Service
// backend/src/services/bygmaScraperService.js
const puppeteer = require('puppeteer');
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
class BygmaScraperService {
constructor() {
this.baseUrl = 'https://www.bygma.dk';
this.loginUrl = 'https://www.bygma.dk/login';
this.credentials = {
username: process.env.BYGMA_USERNAME,
password: process.env.BYGMA_PASSWORD
};
}
/**
* Login til Bygma
*/
async login(page) {
console.log('🔐 Logger ind på Bygma...');
await page.goto(this.loginUrl, { waitUntil: 'networkidle2' });
// Udfyld login formular
await page.type('#username', this.credentials.username);
await page.type('#password', this.credentials.password);
// Klik login knap
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
page.click('button[type="submit"]')
]);
console.log('✅ Login succesfuldt');
}
/**
* Søg efter produkt
*/
async searchProduct(page, productNumber) {
console.log(`🔍 Søger efter produkt: ${productNumber}`);
await page.goto(`${this.baseUrl}/search?q=${productNumber}`, {
waitUntil: 'networkidle2'
});
// Klik på første resultat
const firstProduct = await page.$('.product-item a');
if (!firstProduct) {
throw new Error(`Produkt ${productNumber} ikke fundet`);
}
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
firstProduct.click()
]);
}
/**
* Hent montagevejledning links
*/
async getInstallationGuideLinks(page) {
console.log('📄 Henter montagevejledninger...');
// Find download sektion
const pdfLinks = await page.evaluate(() => {
const links = [];
const downloadSection = document.querySelector('.downloads') ||
document.querySelector('.documents');
if (downloadSection) {
const pdfElements = downloadSection.querySelectorAll('a[href$=".pdf"]');
pdfElements.forEach(el => {
links.push({
title: el.textContent.trim(),
url: el.href
});
});
}
return links;
});
console.log(`✅ Fundet ${pdfLinks.length} PDF'er`);
return pdfLinks;
}
/**
* Download PDF
*/
async downloadPDF(url, filename) {
console.log(`⬇️ Downloader: ${filename}`);
const response = await axios({
method: 'GET',
url: url,
responseType: 'arraybuffer'
});
const filepath = path.join(__dirname, '../../public/manuals', filename);
await fs.writeFile(filepath, response.data);
console.log(`✅ Gemt: ${filepath}`);
return filepath;
}
/**
* Hent alle montagevejledninger for et produkt
*/
async fetchInstallationGuides(productNumber) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
// Login
await this.login(page);
// Søg produkt
await this.searchProduct(page, productNumber);
// Hent PDF links
const pdfLinks = await this.getInstallationGuideLinks(page);
// Download alle PDF'er
const downloadedFiles = [];
for (const link of pdfLinks) {
const filename = `${productNumber}_${link.title.replace(/[^a-z0-9]/gi, '_')}.pdf`;
const filepath = await this.downloadPDF(link.url, filename);
downloadedFiles.push({
title: link.title,
filename,
filepath,
productNumber
});
}
// Gem reference i database
for (const file of downloadedFiles) {
await db.query(`
INSERT INTO installation_manuals (
product_number,
title,
filename,
filepath,
source,
downloaded_at
) VALUES (?, ?, ?, ?, 'bygma', NOW())
`, [file.productNumber, file.title, file.filename, file.filepath]);
}
return downloadedFiles;
} finally {
await browser.close();
}
}
/**
* Batch hent vejledninger for flere produkter
*/
async fetchBulkInstallationGuides(productNumbers) {
const results = {
success: [],
failed: []
};
for (const productNumber of productNumbers) {
try {
const files = await this.fetchInstallationGuides(productNumber);
results.success.push({
productNumber,
files
});
} catch (error) {
console.error(`❌ Fejl ved ${productNumber}:`, error.message);
results.failed.push({
productNumber,
error: error.message
});
}
// Vent mellem requests for at undgå rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
}
return results;
}
/**
* Hent vejledning fra cache hvis den findes
*/
async getManualFromCache(productNumber) {
const manuals = await db.query(
'SELECT * FROM installation_manuals WHERE product_number = ? ORDER BY downloaded_at DESC',
[productNumber]
);
return manuals;
}
}
module.exports = new BygmaScraperService();
Database Schema
-- Tabel til montagevejledninger
CREATE TABLE installation_manuals (
id INT AUTO_INCREMENT PRIMARY KEY,
product_number VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
filename VARCHAR(255) NOT NULL,
filepath VARCHAR(500) NOT NULL,
source ENUM('bygma', 'manual_upload', 'other') DEFAULT 'bygma',
file_size INT COMMENT 'Filstørrelse i bytes',
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_accessed TIMESTAMP NULL,
INDEX idx_product_number (product_number),
INDEX idx_source (source)
);
-- Link vejledninger til materialer
ALTER TABLE package_materials
ADD COLUMN installation_manual_id INT,
ADD FOREIGN KEY (installation_manual_id) REFERENCES installation_manuals(id);
API Endpoints
// Hent montagevejledninger for et produkt
app.post('/api/manuals/fetch/:productNumber', async (req, res) => {
try {
const { productNumber } = req.params;
// Check cache først
const cached = await bygmaScraperService.getManualFromCache(productNumber);
if (cached.length > 0) {
return res.json({
success: true,
source: 'cache',
manuals: cached
});
}
// Hent fra Bygma
const files = await bygmaScraperService.fetchInstallationGuides(productNumber);
res.json({
success: true,
source: 'bygma',
manuals: files
});
} catch (error) {
console.error('Manual fetch error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Batch hent vejledninger
app.post('/api/manuals/fetch-bulk', async (req, res) => {
const { productNumbers } = req.body;
if (!Array.isArray(productNumbers)) {
return res.status(400).json({ error: 'productNumbers skal være et array' });
}
const results = await bygmaScraperService.fetchBulkInstallationGuides(productNumbers);
res.json({ success: true, results });
});
// Hent vejledning fra cache
app.get('/api/manuals/:productNumber', async (req, res) => {
const { productNumber } = req.params;
const manuals = await bygmaScraperService.getManualFromCache(productNumber);
res.json({ success: true, manuals });
});
// Download manual fil
app.get('/api/manuals/:id/download', async (req, res) => {
const { id } = req.params;
const manual = await db.query(
'SELECT * FROM installation_manuals WHERE id = ?',
[id]
);
if (!manual[0]) {
return res.status(404).json({ error: 'Manual ikke fundet' });
}
// Update last accessed
await db.query(
'UPDATE installation_manuals SET last_accessed = NOW() WHERE id = ?',
[id]
);
res.download(manual[0].filepath);
});
Frontend Component
// frontend/src/components/ManualFetcher.js
function ManualFetcher({ productNumber, onFetched }) {
const [loading, setLoading] = useState(false);
const [manuals, setManuals] = useState([]);
const fetchManuals = async () => {
setLoading(true);
try {
const response = await fetch(`/api/manuals/fetch/${productNumber}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
setManuals(result.manuals);
onFetched(result.manuals);
}
} catch (error) {
console.error('Error fetching manuals:', error);
} finally {
setLoading(false);
}
};
return (
<div className="manual-fetcher">
<button onClick={fetchManuals} disabled={loading}>
{loading ? '📥 Henter vejledninger...' : '📚 Hent montagevejledninger'}
</button>
{manuals.length > 0 && (
<div className="manuals-list">
<h4>Montagevejledninger:</h4>
<ul>
{manuals.map(manual => (
<li key={manual.id}>
<a href={`/api/manuals/${manual.id}/download`} target="_blank">
📄 {manual.title}
</a>
<span className="manual-date">
{new Date(manual.downloaded_at).toLocaleDateString('da-DK')}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
📅 Implementation Timeline
Uge 1-2: Foundation
- Database schema updates (alle features)
- Basic service implementations
- API endpoint struktur
Uge 3: Feature 1 & 2
- Task categorization service
- Geometry calculation updates
- Testing og validering
Uge 4: Feature 3 & 4
- Smart pakker udvidelse
- AI description service
- OpenAI integration
Uge 5: Feature 5 & 6
- PDF generation service
- Bygma web scraper
- Testing og optimering
Uge 6: Integration & Testing
- End-to-end testing
- Performance optimization
- Documentation
- Deployment
🧪 Testing Strategy
Unit Tests
// tests/services/taskCategorization.test.js
// tests/services/geometry.test.js
// tests/services/aiDescription.test.js
// tests/services/pdfGeneration.test.js
// tests/services/bygmaScraper.test.js
Integration Tests
// tests/integration/smartPackages.test.js
// tests/integration/finalReview.test.js
E2E Tests
// tests/e2e/createProject.test.js
// tests/e2e/generateQuote.test.js
📝 Environment Variables Required
# OpenAI for AI descriptions
OPENAI_API_KEY=sk-...
# Bygma credentials
BYGMA_USERNAME=your_username
BYGMA_PASSWORD=your_password
# Ordrestyring API
ORDRESTYRING_API_URL=https://api.ordrestyring.dk
ORDRESTYRING_API_TOKEN=your_token
# File paths
MANUALS_DIR=/path/to/manuals
QUOTES_PDF_DIR=/path/to/quotes
Status: 📋 Komplet Roadmap Klar til: Implementation Estimeret Tid: 19-28 dage (4-6 uger)