feat: Implement roof geometry service with complexity calculation, work hour estimation, and carpenter recommendations

- Added RoofGeometryService to handle roof geometry data and calculations.
- Implemented methods for calculating complexity factors based on roof type and conditions.
- Added work hour estimation based on total area and complexity.
- Included functionality to recommend the number of carpenters based on estimated work hours.
- Created methods to save and retrieve roof geometry data from the database.
- Added error handling and logging for database operations.

docs: Update company profile and service descriptions

- Added detailed descriptions of services offered by the company, including roof replacement, renovations, and collaborations with advisors.
- Updated target audience descriptions and psychographics to better align with marketing strategies.
- Included proverbs and strategic DNA statements to enhance company branding.

chore: Add service file for backend deployment

- Created a service file for the backend application to facilitate deployment.
This commit is contained in:
2025-09-15 23:05:56 +02:00
parent 7baa067f5d
commit a02a96667a
19 changed files with 3736 additions and 96 deletions

View File

@@ -0,0 +1,178 @@
-- Customer Project System Database Schema
-- Opretter tabeller for det nye strukturerede tilbuds-flow
-- Kunde projekter
CREATE TABLE IF NOT EXISTS customer_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_description TEXT,
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (project_status),
INDEX idx_customer (customer_name),
INDEX idx_created (created_at)
);
-- Tag geometri specifikt for tag arbejde
CREATE TABLE IF NOT EXISTS roof_geometry (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
roof_type ENUM('fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL,
total_area DECIMAL(10,2) NOT NULL COMMENT 'Samlet areal i m²',
roof_pitch DECIMAL(5,2) COMMENT 'Taghældning i grader',
roof_height DECIMAL(8,2) COMMENT 'Taghøjde i meter',
complexity_factor DECIMAL(3,2) DEFAULT 1.0 COMMENT 'Kompleksitetsfaktor 1.0-2.0',
-- Detaljerede målinger
length_main DECIMAL(8,2) COMMENT 'Hovedlængde i meter',
width_main DECIMAL(8,2) COMMENT 'Hovedbredde i meter',
-- Specielle forhold
has_dormers BOOLEAN DEFAULT FALSE COMMENT 'Har kviste',
has_chimneys BOOLEAN DEFAULT FALSE COMMENT 'Har skorstene',
has_skylights BOOLEAN DEFAULT FALSE COMMENT 'Har ovenlys',
access_difficulty ENUM('let', 'medium', 'svær') DEFAULT 'medium',
-- Beregnet data
estimated_work_hours DECIMAL(8,2) COMMENT 'Estimerede arbejdstimer',
estimated_carpenters INT COMMENT 'Anbefalede antal tømrere',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_roof_type (roof_type)
);
-- Arbejdstimer og tømrere
CREATE TABLE IF NOT EXISTS project_labor (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
carpenter_count INT NOT NULL DEFAULT 1,
estimated_hours_per_carpenter DECIMAL(8,2) NOT NULL,
total_work_hours DECIMAL(8,2) NOT NULL,
hourly_rate DECIMAL(8,2) NOT NULL DEFAULT 580.00 COMMENT 'Fast pris 580 kr/time',
total_labor_cost DECIMAL(12,2) NOT NULL,
-- Detaljer om arbejdsopgaver
work_breakdown JSON COMMENT 'Detaljeret opdeling af arbejdsopgaver',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
);
-- Projekt materialer
CREATE TABLE IF NOT EXISTS project_materials (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
material_name VARCHAR(255) NOT NULL,
material_category VARCHAR(100),
quantity DECIMAL(10,3) NOT NULL,
unit VARCHAR(50) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(12,2) NOT NULL,
supplier VARCHAR(255),
material_source ENUM('manual', 'database', 'bygma_api') DEFAULT 'manual',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_category (material_category)
);
-- Projekt beregninger (råtilbud)
CREATE TABLE IF NOT EXISTS project_calculations (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
-- Geometri opsummering
total_area DECIMAL(10,2),
complexity_factor DECIMAL(3,2),
-- Arbejdstimer opsummering
total_work_hours DECIMAL(8,2),
carpenter_count INT,
total_labor_cost DECIMAL(12,2),
-- Materialer opsummering
total_material_cost DECIMAL(12,2),
material_count INT,
-- Totaler
subtotal DECIMAL(12,2),
overhead_percentage DECIMAL(5,2) DEFAULT 15.00,
overhead_amount DECIMAL(12,2),
profit_percentage DECIMAL(5,2) DEFAULT 20.00,
profit_amount DECIMAL(12,2),
total_excl_vat DECIMAL(12,2),
vat_percentage DECIMAL(5,2) DEFAULT 25.00,
vat_amount DECIMAL(12,2),
total_incl_vat DECIMAL(12,2),
-- Metadata
calculation_data JSON COMMENT 'Detaljerede beregningsdata',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
);
-- Genererede tilbud
CREATE TABLE IF NOT EXISTS generated_quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
calculation_id INT NOT NULL,
quote_text TEXT NOT NULL COMMENT 'AI-genereret tilbudstekst',
quote_format ENUM('text', 'html', 'pdf') DEFAULT 'html',
-- AI usage tracking
ai_tokens_used INT,
ai_cost DECIMAL(8,4),
ai_model VARCHAR(50),
quote_status ENUM('draft', 'approved', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
sent_at TIMESTAMP NULL,
valid_until DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
FOREIGN KEY (calculation_id) REFERENCES project_calculations(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_status (quote_status)
);
-- Bygma API cache (for version 2)
CREATE TABLE IF NOT EXISTS bygma_materials_cache (
id INT AUTO_INCREMENT PRIMARY KEY,
product_id VARCHAR(100) UNIQUE,
product_name VARCHAR(255),
category VARCHAR(100),
subcategory VARCHAR(100),
unit VARCHAR(50),
price DECIMAL(10,2),
stock_status VARCHAR(50),
supplier_info JSON,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_category (category),
INDEX idx_name (product_name),
INDEX idx_updated (last_updated)
);

View File

@@ -0,0 +1,818 @@
const express = require('express');
const router = express.Router();
const CustomerProjectService = require('../services/customerProjectService');
const RoofGeometryService = require('../services/roofGeometryService');
const ProjectLaborService = require('../services/projectLaborService');
const ProjectMaterialService = require('../services/projectMaterialService');
const ProjectCalculationService = require('../services/projectCalculationService');
const ProjectQuoteGenerationService = require('../services/projectQuoteGenerationService');
const databaseService = require('../services/databaseService');
const openaiService = require('../services/openaiService');
const logger = require('../utils/logger');
// Initialize services
const customerProjectService = new CustomerProjectService(databaseService);
const roofGeometryService = new RoofGeometryService(databaseService);
const projectLaborService = new ProjectLaborService(databaseService);
const projectMaterialService = new ProjectMaterialService(databaseService);
const projectCalculationService = new ProjectCalculationService(databaseService);
const projectQuoteGenerationService = new ProjectQuoteGenerationService(databaseService, openaiService);
// ==================== KUNDE PROJEKT ENDPOINTS ====================
// Opret nyt kunde projekt
router.post('/projects', async (req, res) => {
try {
const projectData = req.body;
// Validering
if (!projectData.projectName || !projectData.customerName) {
return res.status(400).json({
success: false,
error: 'Projekt navn og kunde navn er påkrævet'
});
}
const result = await customerProjectService.createProject(projectData);
res.json({
success: true,
project: result,
message: 'Kunde projekt oprettet succesfuldt'
});
} catch (error) {
logger.error('Error creating customer project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved oprettelse af kunde projekt'
});
}
});
// Hent alle projekter med pagination
router.get('/projects', async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const status = req.query.status;
const result = await customerProjectService.getProjects(page, limit, status);
res.json({
success: true,
...result
});
} catch (error) {
logger.error('Error getting projects:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekter'
});
}
});
// Hent specifikt projekt med alle detaljer
router.get('/projects/:id', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Ugyldigt projekt ID'
});
}
const result = await customerProjectService.getProjectWithDetails(projectId);
if (!result) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
res.json({
success: true,
...result
});
} catch (error) {
logger.error('Error getting project details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt detaljer'
});
}
});
// Opdater projekt status
router.patch('/projects/:id/status', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { status } = req.body;
const validStatuses = [
'draft', 'geometry_pending', 'labor_pending', 'materials_pending',
'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected'
];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: 'Ugyldig status'
});
}
await customerProjectService.updateProjectStatus(projectId, status);
res.json({
success: true,
message: 'Projekt status opdateret'
});
} catch (error) {
logger.error('Error updating project status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af projekt status'
});
}
});
// Slet projekt
router.delete('/projects/:id', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
await customerProjectService.deleteProject(projectId);
res.json({
success: true,
message: 'Projekt slettet succesfuldt'
});
} catch (error) {
logger.error('Error deleting project:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af projekt'
});
}
});
// ==================== TAG GEOMETRI ENDPOINTS ====================
// Gem tag geometri
router.post('/projects/:id/geometry', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const geometryData = req.body;
// Validering
if (!geometryData.roofType || !geometryData.totalArea) {
return res.status(400).json({
success: false,
error: 'Tag type og areal er påkrævet'
});
}
const result = await roofGeometryService.saveRoofGeometry(projectId, geometryData);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'labor_pending');
res.json({
success: true,
geometry: result,
message: 'Tag geometri gemt succesfuldt'
});
} catch (error) {
logger.error('Error saving roof geometry:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gem af tag geometri'
});
}
});
// Hent tag geometri
router.get('/projects/:id/geometry', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const geometry = await roofGeometryService.getRoofGeometry(projectId);
res.json({
success: true,
geometry: geometry
});
} catch (error) {
logger.error('Error getting roof geometry:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af tag geometri'
});
}
});
// Genberegn geometri estimater
router.post('/projects/:id/geometry/recalculate', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const result = await roofGeometryService.recalculateEstimates(projectId);
res.json({
success: true,
estimates: result,
message: 'Estimater genberegnet'
});
} catch (error) {
logger.error('Error recalculating geometry estimates:', error);
res.status(500).json({
success: false,
error: 'Fejl ved genberegning af estimater'
});
}
});
// ==================== ARBEJDSTIMER ENDPOINTS ====================
// Gem arbejdstimer og tømrere
router.post('/projects/:id/labor', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const laborData = req.body;
// Validering
if (!laborData.carpenterCount || !laborData.totalWorkHours) {
return res.status(400).json({
success: false,
error: 'Antal tømrere og samlet arbejdstimer er påkrævet'
});
}
const result = await projectLaborService.saveProjectLabor(projectId, laborData);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'materials_pending');
res.json({
success: true,
labor: result,
message: 'Arbejdstimer gemt succesfuldt'
});
} catch (error) {
logger.error('Error saving project labor:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gem af arbejdstimer'
});
}
});
// Hent arbejdstimer data
router.get('/projects/:id/labor', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const labor = await projectLaborService.getProjectLabor(projectId);
res.json({
success: true,
labor: labor
});
} catch (error) {
logger.error('Error getting project labor:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af arbejdstimer'
});
}
});
// Estimer arbejdstimer baseret på geometri
router.get('/projects/:id/labor/estimate', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const estimate = await projectLaborService.estimateLaborFromGeometry(projectId);
res.json({
success: true,
estimate: estimate
});
} catch (error) {
logger.error('Error estimating labor from geometry:', error);
res.status(500).json({
success: false,
error: 'Fejl ved estimering af arbejdstimer'
});
}
});
// Genberegn arbejdsomkostninger
router.post('/projects/:id/labor/recalculate', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const result = await projectLaborService.recalculateLaborCosts(projectId);
res.json({
success: true,
costs: result,
message: 'Arbejdsomkostninger genberegnet'
});
} catch (error) {
logger.error('Error recalculating labor costs:', error);
res.status(500).json({
success: false,
error: 'Fejl ved genberegning af arbejdsomkostninger'
});
}
});
// ==================== MATERIALE ENDPOINTS ====================
// Tilføj materiale til projekt
router.post('/projects/:id/materials', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const materialData = req.body;
// Validering
if (!materialData.materialName || !materialData.quantity || !materialData.unitPrice) {
return res.status(400).json({
success: false,
error: 'Materialnavn, mængde og enhedspris er påkrævet'
});
}
const result = await projectMaterialService.addProjectMaterial(projectId, materialData);
res.json({
success: true,
material: result,
message: 'Materiale tilføjet succesfuldt'
});
} catch (error) {
logger.error('Error adding project material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tilføjelse af materiale'
});
}
});
// Hent alle materialer for projekt
router.get('/projects/:id/materials', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const result = await projectMaterialService.getProjectMaterials(projectId);
res.json({
success: true,
...result
});
} catch (error) {
logger.error('Error getting project materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialer'
});
}
});
// Opdater materiale
router.put('/projects/:projectId/materials/:materialId', async (req, res) => {
try {
const materialId = parseInt(req.params.materialId);
const updateData = req.body;
const result = await projectMaterialService.updateProjectMaterial(materialId, updateData);
res.json({
success: true,
material: result,
message: 'Materiale opdateret succesfuldt'
});
} catch (error) {
logger.error('Error updating project material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved opdatering af materiale'
});
}
});
// Slet materiale
router.delete('/projects/:projectId/materials/:materialId', async (req, res) => {
try {
const materialId = parseInt(req.params.materialId);
await projectMaterialService.deleteProjectMaterial(materialId);
res.json({
success: true,
message: 'Materiale slettet succesfuldt'
});
} catch (error) {
logger.error('Error deleting project material:', error);
res.status(500).json({
success: false,
error: 'Fejl ved sletning af materiale'
});
}
});
// Bulk tilføj materialer
router.post('/projects/:id/materials/bulk', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { materials } = req.body;
if (!Array.isArray(materials) || materials.length === 0) {
return res.status(400).json({
success: false,
error: 'Materialer array er påkrævet'
});
}
const result = await projectMaterialService.bulkAddMaterials(projectId, materials);
// Opdater projekt status til calculation_ready hvis alle materialer er tilføjet
await customerProjectService.updateProjectStatus(projectId, 'calculation_ready');
res.json({
success: true,
...result,
message: `${result.count} materialer tilføjet succesfuldt`
});
} catch (error) {
logger.error('Error bulk adding materials:', error);
res.status(500).json({
success: false,
error: 'Fejl ved bulk tilføjelse af materialer'
});
}
});
// Søg i eksisterende materiale database
router.get('/projects/:id/materials/search', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const searchCriteria = {
category: req.query.category,
searchTerm: req.query.search,
limit: parseInt(req.query.limit) || 50
};
const results = await projectMaterialService.importMaterialsFromDatabase(projectId, searchCriteria);
res.json({
success: true,
materials: results,
count: results.length
});
} catch (error) {
logger.error('Error searching materials database:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning i materiale database'
});
}
});
// Forslag til tagmaterialer baseret på geometri
router.get('/projects/:id/materials/suggestions', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const suggestions = await projectMaterialService.suggestRoofMaterials(projectId);
res.json({
success: true,
suggestions: suggestions,
message: 'Materialeforslag genereret baseret på taggeometri'
});
} catch (error) {
logger.error('Error getting material suggestions:', error);
res.status(500).json({
success: false,
error: 'Fejl ved generering af materialeforslag'
});
}
});
// Beregn materialeomkostninger
router.get('/projects/:id/materials/costs', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const costs = await projectMaterialService.calculateMaterialCosts(projectId);
res.json({
success: true,
...costs
});
} catch (error) {
logger.error('Error calculating material costs:', error);
res.status(500).json({
success: false,
error: 'Fejl ved beregning af materialeomkostninger'
});
}
});
// ==================== BEREGNINGS ENDPOINTS ====================
// Beregn projekt tilbud (råtilbud)
router.post('/projects/:id/calculate', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const options = req.body || {};
const result = await projectCalculationService.calculateProjectQuote(projectId, options);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'quote_generated');
res.json({
success: true,
...result,
message: 'Projekt beregnet succesfuldt'
});
} catch (error) {
logger.error('Error calculating project quote:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved beregning af projekt tilbud'
});
}
});
// Hent seneste beregning
router.get('/projects/:id/calculation', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const calculation = await projectCalculationService.getLatestCalculation(projectId);
if (!calculation) {
return res.status(404).json({
success: false,
error: 'Ingen beregning fundet for dette projekt'
});
}
res.json({
success: true,
calculation: calculation
});
} catch (error) {
logger.error('Error getting project calculation:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af projekt beregning'
});
}
});
// ==================== HJÆLPE ENDPOINTS ====================
// Hent tag typer og muligheder
router.get('/roof-types', (req, res) => {
const roofTypes = [
{ value: 'fladt_tag', label: 'Fladt tag', baseComplexity: 1.0 },
{ value: 'skraat_tag', label: 'Skråt tag', baseComplexity: 1.2 },
{ value: 'mansard', label: 'Mansardtag', baseComplexity: 1.5 },
{ value: 'komplekst', label: 'Komplekst tag', baseComplexity: 1.8 }
];
const accessDifficulties = [
{ value: 'let', label: 'Let adgang', factor: 0.0 },
{ value: 'medium', label: 'Medium adgang', factor: 0.1 },
{ value: 'svær', label: 'Svær adgang', factor: 0.3 }
];
res.json({
success: true,
roofTypes,
accessDifficulties
});
});
// Hent material kategorier
router.get('/material-categories', async (req, res) => {
try {
const categories = await projectMaterialService.getMaterialCategories();
res.json({
success: true,
categories
});
} catch (error) {
logger.error('Error getting material categories:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af material kategorier'
});
}
});
// POST /api/customer-projects/:id/calculate - Udfør beregninger og opret rå tilbud
router.post('/:id/calculate', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { overrideSettings } = req.body;
const result = await projectCalculationService.calculateProjectQuote(projectId, overrideSettings);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('Error calculating project quote:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/customer-projects/:id/calculation - Hent seneste beregning
router.get('/:id/calculation', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const [rows] = await databaseService.pool.execute(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
if (rows.length === 0) {
return res.status(404).json({
success: false,
error: 'No calculations found for this project'
});
}
res.json({
success: true,
data: rows[0]
});
} catch (error) {
logger.error('Error getting project calculation:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/customer-projects/:id/generate-quote - Generer professionelt tilbud med AI
router.post('/:id/generate-quote', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { calculationId, options } = req.body;
if (!calculationId) {
return res.status(400).json({
success: false,
error: 'Calculation ID is required'
});
}
const result = await projectQuoteGenerationService.generateProfessionalQuote(
projectId,
calculationId,
options
);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('Error generating professional quote:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/customer-projects/:id/generate-static-quote - Generer tilbud med kun statisk indhold (gratis)
router.post('/:id/generate-static-quote', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { calculationId, options } = req.body;
if (!calculationId) {
return res.status(400).json({
success: false,
error: 'Calculation ID is required'
});
}
const result = await projectQuoteGenerationService.generateStaticQuote(
projectId,
calculationId,
options
);
res.json({
success: true,
data: result,
message: 'Statisk tilbud genereret uden AI omkostninger'
});
} catch (error) {
logger.error('Error generating static quote:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/customer-projects/:id/quotes - Hent alle tilbud for projekt
router.get('/:id/quotes', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const quotes = await projectQuoteGenerationService.getProjectQuotes(projectId);
res.json({
success: true,
data: quotes
});
} catch (error) {
logger.error('Error getting project quotes:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// PUT /api/customer-projects/quotes/:quoteId/status - Opdater tilbud status
router.put('/quotes/:quoteId/status', async (req, res) => {
try {
const quoteId = parseInt(req.params.quoteId);
const { status, sentAt } = req.body;
const validStatuses = ['draft', 'sent', 'accepted', 'rejected', 'expired'];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: 'Invalid status. Must be one of: ' + validStatuses.join(', ')
});
}
await projectQuoteGenerationService.updateQuoteStatus(quoteId, status, sentAt);
res.json({
success: true,
message: 'Quote status updated successfully'
});
} catch (error) {
logger.error('Error updating quote status:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/customer-projects/quotes/:quoteId/revise - Generer revideret tilbud
router.post('/quotes/:quoteId/revise', async (req, res) => {
try {
const quoteId = parseInt(req.params.quoteId);
const { revisionInstructions } = req.body;
if (!revisionInstructions) {
return res.status(400).json({
success: false,
error: 'Revision instructions are required'
});
}
const result = await projectQuoteGenerationService.generateRevisedQuote(
quoteId,
revisionInstructions
);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('Error generating revised quote:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;

View File

@@ -0,0 +1,213 @@
const logger = require('../utils/logger');
class CustomerProjectService {
constructor(databaseService) {
this.db = databaseService;
}
// Opret nyt kunde projekt
async createProject(projectData) {
try {
const {
projectName,
customerName,
customerEmail,
customerPhone,
customerAddress,
projectDescription
} = projectData;
const query = `
INSERT INTO customer_projects (
project_name, customer_name, customer_email,
customer_phone, customer_address, project_description
) VALUES (?, ?, ?, ?, ?, ?)
`;
const [result] = await this.db.pool.execute(query, [
projectName,
customerName,
customerEmail,
customerPhone,
customerAddress,
projectDescription
]);
logger.info('Customer project created', {
projectId: result.insertId,
projectName,
customerName
});
return {
id: result.insertId,
projectName,
customerName,
status: 'draft',
created_at: new Date()
};
} catch (error) {
logger.error('Error creating customer project:', error);
throw error;
}
}
// Hent projekt med alle relaterede data
async getProjectWithDetails(projectId) {
try {
// Hent grundlæggende projekt info
const [projectRows] = await this.db.pool.execute(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (projectRows.length === 0) {
return null;
}
const project = projectRows[0];
// Hent geometri data
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Hent arbejdstimer data
const [laborRows] = await this.db.pool.execute(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
// Hent materialer
const [materialRows] = await this.db.pool.execute(
'SELECT * FROM project_materials WHERE project_id = ? ORDER BY material_category, material_name',
[projectId]
);
// Hent beregninger
const [calculationRows] = await this.db.pool.execute(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
// Hent genererede tilbud
const [quoteRows] = await this.db.pool.execute(
'SELECT * FROM generated_quotes WHERE project_id = ? ORDER BY created_at DESC',
[projectId]
);
return {
project,
geometry: geometryRows[0] || null,
labor: laborRows[0] || null,
materials: materialRows,
calculation: calculationRows[0] || null,
quotes: quoteRows
};
} catch (error) {
logger.error('Error getting project details:', error);
throw error;
}
}
// Opdater projekt status
async updateProjectStatus(projectId, status) {
try {
const query = `
UPDATE customer_projects
SET project_status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
await this.db.pool.execute(query, [status, projectId]);
logger.info('Project status updated', { projectId, status });
return true;
} catch (error) {
logger.error('Error updating project status:', error);
throw error;
}
}
// Hent alle projekter med pagination
async getProjects(page = 1, limit = 20, status = null) {
try {
const offset = (page - 1) * limit;
let query = `
SELECT cp.*,
rg.total_area,
pl.total_labor_cost,
pc.total_incl_vat,
COUNT(pm.id) as material_count
FROM customer_projects cp
LEFT JOIN roof_geometry rg ON cp.id = rg.project_id
LEFT JOIN project_labor pl ON cp.id = pl.project_id
LEFT JOIN project_calculations pc ON cp.id = pc.project_id
LEFT JOIN project_materials pm ON cp.id = pm.project_id
`;
let params = [];
if (status) {
query += ' WHERE cp.project_status = ?';
params.push(status);
}
query += `
GROUP BY cp.id
ORDER BY cp.updated_at DESC
LIMIT ? OFFSET ?
`;
params.push(limit, offset);
const [rows] = await this.db.pool.execute(query, params);
// Hent total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM customer_projects';
let countParams = [];
if (status) {
countQuery += ' WHERE project_status = ?';
countParams.push(status);
}
const [countRows] = await this.db.pool.execute(countQuery, countParams);
return {
projects: rows,
pagination: {
currentPage: page,
totalPages: Math.ceil(countRows[0].total / limit),
totalProjects: countRows[0].total,
projectsPerPage: limit
}
};
} catch (error) {
logger.error('Error getting projects:', error);
throw error;
}
}
// Slet projekt og alle relaterede data
async deleteProject(projectId) {
try {
const query = 'DELETE FROM customer_projects WHERE id = ?';
const [result] = await this.db.pool.execute(query, [projectId]);
if (result.affectedRows === 0) {
throw new Error('Project not found');
}
logger.info('Project deleted', { projectId });
return true;
} catch (error) {
logger.error('Error deleting project:', error);
throw error;
}
}
}
module.exports = CustomerProjectService;

View File

@@ -596,6 +596,161 @@ class DatabaseService {
)
`);
// Create customer projects system tables
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS customer_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_description TEXT,
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (project_status),
INDEX idx_customer (customer_name),
INDEX idx_created (created_at)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS roof_geometry (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
roof_type ENUM('fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL,
total_area DECIMAL(10,2) NOT NULL COMMENT 'Samlet areal i m²',
roof_pitch DECIMAL(5,2) COMMENT 'Taghældning i grader',
roof_height DECIMAL(8,2) COMMENT 'Taghøjde i meter',
complexity_factor DECIMAL(3,2) DEFAULT 1.0 COMMENT 'Kompleksitetsfaktor 1.0-2.0',
length_main DECIMAL(8,2) COMMENT 'Hovedlængde i meter',
width_main DECIMAL(8,2) COMMENT 'Hovedbredde i meter',
has_dormers BOOLEAN DEFAULT FALSE COMMENT 'Har kviste',
has_chimneys BOOLEAN DEFAULT FALSE COMMENT 'Har skorstene',
has_skylights BOOLEAN DEFAULT FALSE COMMENT 'Har ovenlys',
access_difficulty ENUM('let', 'medium', 'svær') DEFAULT 'medium',
estimated_work_hours DECIMAL(8,2) COMMENT 'Estimerede arbejdstimer',
estimated_carpenters INT COMMENT 'Anbefalede antal tømrere',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_roof_type (roof_type)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_labor (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
carpenter_count INT NOT NULL DEFAULT 1,
estimated_hours_per_carpenter DECIMAL(8,2) NOT NULL,
total_work_hours DECIMAL(8,2) NOT NULL,
hourly_rate DECIMAL(8,2) NOT NULL DEFAULT 580.00 COMMENT 'Fast pris 580 kr/time',
total_labor_cost DECIMAL(12,2) NOT NULL,
work_breakdown JSON COMMENT 'Detaljeret opdeling af arbejdsopgaver',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_materials (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
material_name VARCHAR(255) NOT NULL,
material_category VARCHAR(100),
quantity DECIMAL(10,3) NOT NULL,
unit VARCHAR(50) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(12,2) NOT NULL,
supplier VARCHAR(255),
material_source ENUM('manual', 'database', 'bygma_api') DEFAULT 'manual',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_category (material_category)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_calculations (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
total_area DECIMAL(10,2),
complexity_factor DECIMAL(3,2),
total_work_hours DECIMAL(8,2),
carpenter_count INT,
total_labor_cost DECIMAL(12,2),
total_material_cost DECIMAL(12,2),
material_count INT,
subtotal DECIMAL(12,2),
overhead_percentage DECIMAL(5,2) DEFAULT 15.00,
overhead_amount DECIMAL(12,2),
profit_percentage DECIMAL(5,2) DEFAULT 20.00,
profit_amount DECIMAL(12,2),
total_excl_vat DECIMAL(12,2),
vat_percentage DECIMAL(5,2) DEFAULT 25.00,
vat_amount DECIMAL(12,2),
total_incl_vat DECIMAL(12,2),
calculation_data JSON COMMENT 'Detaljerede beregningsdata',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS generated_quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
calculation_id INT NOT NULL,
quote_text TEXT NOT NULL COMMENT 'AI-genereret tilbudstekst',
quote_format ENUM('text', 'html', 'pdf') DEFAULT 'html',
ai_tokens_used INT,
ai_cost DECIMAL(8,4),
ai_model VARCHAR(50),
quote_status ENUM('draft', 'approved', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
sent_at TIMESTAMP NULL,
valid_until DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
FOREIGN KEY (calculation_id) REFERENCES project_calculations(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_status (quote_status)
)
`);
logger.info('Database tables created/verified successfully');
} catch (error) {
logger.error('Error creating database tables:', error);

View File

@@ -0,0 +1,403 @@
const logger = require('../utils/logger');
class ProjectCalculationService {
constructor(databaseService) {
this.db = databaseService;
// Standard procentsatser
this.DEFAULT_OVERHEAD_PERCENTAGE = 15.00;
this.DEFAULT_PROFIT_PERCENTAGE = 20.00;
this.DEFAULT_VAT_PERCENTAGE = 25.00;
}
// Samle alle projektdata til beregning
async gatherProjectData(projectId) {
try {
// Hent grundlæggende projekt info
const [projectRows] = await this.db.pool.execute(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
if (projectRows.length === 0) {
throw new Error('Project not found');
}
// Hent geometri data
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Hent arbejdstimer data
const [laborRows] = await this.db.pool.execute(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
// Hent materialer
const [materialRows] = await this.db.pool.execute(
'SELECT * FROM project_materials WHERE project_id = ? ORDER BY material_category, material_name',
[projectId]
);
// Beregn material totaler
const materialTotals = this.calculateMaterialTotals(materialRows);
return {
project: projectRows[0],
geometry: geometryRows[0] || null,
labor: laborRows[0] || null,
materials: materialRows,
materialTotals
};
} catch (error) {
logger.error('Error gathering project data:', error);
throw error;
}
}
// Beregn material totaler
calculateMaterialTotals(materials) {
const totals = {
totalCost: 0,
materialCount: materials.length,
categoryBreakdown: {}
};
materials.forEach(material => {
const category = material.material_category || 'Øvrige';
const cost = parseFloat(material.total_price);
totals.totalCost += cost;
if (!totals.categoryBreakdown[category]) {
totals.categoryBreakdown[category] = {
cost: 0,
count: 0
};
}
totals.categoryBreakdown[category].cost += cost;
totals.categoryBreakdown[category].count += 1;
});
return totals;
}
// Hovedberegningsmetode
async calculateProjectQuote(projectId, options = {}) {
try {
const {
overheadPercentage = this.DEFAULT_OVERHEAD_PERCENTAGE,
profitPercentage = this.DEFAULT_PROFIT_PERCENTAGE,
vatPercentage = this.DEFAULT_VAT_PERCENTAGE,
customAdjustments = {}
} = options;
// Samle alle data
const projectData = await this.gatherProjectData(projectId);
// Validér at alle nødvendige data er tilstede
this.validateProjectData(projectData);
// Udføre beregninger
const calculations = this.performCalculations(
projectData,
overheadPercentage,
profitPercentage,
vatPercentage,
customAdjustments
);
// Gem beregningerne i database
const savedCalculation = await this.saveCalculation(projectId, calculations);
logger.info('Project calculation completed', {
projectId,
totalInclVat: calculations.totalInclVat,
laborCost: calculations.totalLaborCost,
materialCost: calculations.totalMaterialCost
});
return {
calculationId: savedCalculation.id,
projectData,
calculations,
breakdown: this.createDetailedBreakdown(projectData, calculations)
};
} catch (error) {
logger.error('Error calculating project quote:', error);
throw error;
}
}
// Validér at alle nødvendige data er tilstede
validateProjectData(projectData) {
const errors = [];
if (!projectData.geometry) {
errors.push('Geometri data mangler');
}
if (!projectData.labor) {
errors.push('Arbejdstimer data mangler');
}
if (projectData.materials.length === 0) {
errors.push('Ingen materialer tilføjet');
}
if (errors.length > 0) {
throw new Error(`Projekt data er ufuldstændig: ${errors.join(', ')}`);
}
}
// Hovedberegninger
performCalculations(projectData, overheadPercentage, profitPercentage, vatPercentage, customAdjustments) {
const { geometry, labor, materialTotals } = projectData;
// Basis omkostninger
const totalLaborCost = parseFloat(labor.total_labor_cost);
const totalMaterialCost = materialTotals.totalCost;
// Subtotal (labor + materialer)
const subtotal = totalLaborCost + totalMaterialCost;
// Overhead beregning
const overheadAmount = subtotal * (overheadPercentage / 100);
// Profit beregning (på subtotal + overhead)
const subtotalWithOverhead = subtotal + overheadAmount;
const profitAmount = subtotalWithOverhead * (profitPercentage / 100);
// Total ekskl. moms
const totalExclVat = subtotalWithOverhead + profitAmount;
// Moms beregning
const vatAmount = totalExclVat * (vatPercentage / 100);
// Total inkl. moms
const totalInclVat = totalExclVat + vatAmount;
// Tilføj eventuele custom justeringer
const adjustments = this.applyCustomAdjustments(totalInclVat, customAdjustments);
return {
// Basis data
totalArea: parseFloat(geometry.total_area),
complexityFactor: parseFloat(geometry.complexity_factor),
totalWorkHours: parseFloat(labor.total_work_hours),
carpenterCount: parseInt(labor.carpenter_count),
// Omkostninger
totalLaborCost,
totalMaterialCost,
materialCount: materialTotals.materialCount,
// Beregninger
subtotal,
overheadPercentage,
overheadAmount,
profitPercentage,
profitAmount,
totalExclVat,
vatPercentage,
vatAmount,
totalInclVat,
// Justeringer
adjustments,
finalTotal: totalInclVat + (adjustments.totalAdjustment || 0),
// Priser per enhed
pricePerM2: totalInclVat / parseFloat(geometry.total_area),
pricePerHour: totalInclVat / parseFloat(labor.total_work_hours)
};
}
// Anvend custom justeringer
applyCustomAdjustments(baseTotal, customAdjustments) {
let totalAdjustment = 0;
const adjustmentDetails = [];
// Rabat
if (customAdjustments.discount) {
const discount = baseTotal * (customAdjustments.discount / 100);
totalAdjustment -= discount;
adjustmentDetails.push({
type: 'discount',
description: `Rabat ${customAdjustments.discount}%`,
amount: -discount
});
}
// Tillæg
if (customAdjustments.surcharge) {
const surcharge = baseTotal * (customAdjustments.surcharge / 100);
totalAdjustment += surcharge;
adjustmentDetails.push({
type: 'surcharge',
description: `Tillæg ${customAdjustments.surcharge}%`,
amount: surcharge
});
}
// Fast tillæg/fradrag
if (customAdjustments.fixedAmount) {
totalAdjustment += customAdjustments.fixedAmount;
adjustmentDetails.push({
type: 'fixed',
description: customAdjustments.fixedDescription || 'Ekstra omkostning',
amount: customAdjustments.fixedAmount
});
}
return {
totalAdjustment,
details: adjustmentDetails
};
}
// Opret detaljeret breakdown
createDetailedBreakdown(projectData, calculations) {
const { project, geometry, labor, materials, materialTotals } = projectData;
return {
projectInfo: {
name: project.project_name,
customer: project.customer_name,
description: project.project_description
},
geometryBreakdown: {
roofType: geometry.roof_type,
totalArea: geometry.total_area,
complexity: geometry.complexity_factor,
specialFeatures: {
dormers: geometry.has_dormers,
chimneys: geometry.has_chimneys,
skylights: geometry.has_skylights
}
},
laborBreakdown: {
carpenterCount: labor.carpenter_count,
totalHours: labor.total_work_hours,
hoursPerCarpenter: labor.estimated_hours_per_carpenter,
hourlyRate: labor.hourly_rate,
totalCost: labor.total_labor_cost,
workBreakdown: labor.work_breakdown ? JSON.parse(labor.work_breakdown) : []
},
materialBreakdown: {
totalCost: materialTotals.totalCost,
materialCount: materialTotals.materialCount,
categoryBreakdown: materialTotals.categoryBreakdown,
materials: materials.map(m => ({
name: m.material_name,
category: m.material_category,
quantity: m.quantity,
unit: m.unit,
unitPrice: m.unit_price,
totalPrice: m.total_price,
supplier: m.supplier
}))
},
financialBreakdown: {
subtotal: calculations.subtotal,
overhead: {
percentage: calculations.overheadPercentage,
amount: calculations.overheadAmount
},
profit: {
percentage: calculations.profitPercentage,
amount: calculations.profitAmount
},
vat: {
percentage: calculations.vatPercentage,
amount: calculations.vatAmount
},
total: calculations.totalInclVat,
adjustments: calculations.adjustments
}
};
}
// Gem beregning i database
async saveCalculation(projectId, calculations) {
try {
const query = `
INSERT INTO project_calculations (
project_id, total_area, complexity_factor, total_work_hours,
carpenter_count, total_labor_cost, total_material_cost, material_count,
subtotal, overhead_percentage, overhead_amount, profit_percentage,
profit_amount, total_excl_vat, vat_percentage, vat_amount,
total_incl_vat, calculation_data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.db.pool.execute(query, [
projectId,
calculations.totalArea,
calculations.complexityFactor,
calculations.totalWorkHours,
calculations.carpenterCount,
calculations.totalLaborCost,
calculations.totalMaterialCost,
calculations.materialCount,
calculations.subtotal,
calculations.overheadPercentage,
calculations.overheadAmount,
calculations.profitPercentage,
calculations.profitAmount,
calculations.totalExclVat,
calculations.vatPercentage,
calculations.vatAmount,
calculations.totalInclVat,
JSON.stringify(calculations)
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving calculation:', error);
throw error;
}
}
// Hent seneste beregning for projekt
async getLatestCalculation(projectId) {
try {
const [rows] = await this.db.pool.execute(
`SELECT * FROM project_calculations
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT 1`,
[projectId]
);
if (rows.length === 0) {
return null;
}
const calculation = rows[0];
// Parse calculation data
let calculationData = {};
if (calculation.calculation_data) {
try {
calculationData = JSON.parse(calculation.calculation_data);
} catch (e) {
logger.warn('Failed to parse calculation data JSON:', e);
}
}
return {
...calculation,
calculation_data: calculationData
};
} catch (error) {
logger.error('Error getting latest calculation:', error);
throw error;
}
}
}
module.exports = ProjectCalculationService;

View File

@@ -0,0 +1,276 @@
const logger = require('../utils/logger');
class ProjectLaborService {
constructor(databaseService) {
this.db = databaseService;
this.HOURLY_RATE = 580.00; // Fast pris som aftalt
}
// Beregn timer per tømrer baseret på geometri
calculateHoursPerCarpenter(totalHours, carpenterCount) {
return Math.ceil(totalHours / carpenterCount);
}
// Beregn optimal arbejdsfordeling
calculateWorkDistribution(totalHours, suggestedCarpenters) {
const hoursPerCarpenter = this.calculateHoursPerCarpenter(totalHours, suggestedCarpenters);
// Arbejdsdage (8 timer per dag)
const daysPerCarpenter = Math.ceil(hoursPerCarpenter / 8);
const totalWorkDays = Math.ceil(totalHours / (8 * suggestedCarpenters));
return {
carpenterCount: suggestedCarpenters,
hoursPerCarpenter,
daysPerCarpenter,
totalWorkDays,
totalHours,
efficiency: totalHours / (suggestedCarpenters * hoursPerCarpenter) // Efficiency ratio
};
}
// Opdel arbejdet i opgaver for tag arbejde
createWorkBreakdown(geometryData, totalHours) {
const breakdown = [];
// Standard tag arbejde opgaver med typiske tidsfordeling
const taskTemplates = {
'preparation': { name: 'Forberedelse og opstilling', percentage: 0.10 },
'removal': { name: 'Fjernelse af gammelt tag', percentage: 0.15 },
'structure_repair': { name: 'Reparation af tagkonstruktion', percentage: 0.20 },
'insulation': { name: 'Isolering', percentage: 0.15 },
'roofing_material': { name: 'Lægning af tagmateriale', percentage: 0.25 },
'finishing': { name: 'Afslutning og rengøring', percentage: 0.10 },
'special_work': { name: 'Specialarbejde (kviste, skorstene)', percentage: 0.05 }
};
// Juster procenter baseret på tag type og kompleksitet
let adjustedTasks = { ...taskTemplates };
if (geometryData.roof_type === 'fladt_tag') {
adjustedTasks.roofing_material.percentage = 0.30; // Mere lægning af materialer
adjustedTasks.structure_repair.percentage = 0.15; // Mindre strukturelt
} else if (geometryData.roof_type === 'komplekst') {
adjustedTasks.special_work.percentage = 0.15; // Mere specialarbejde
adjustedTasks.structure_repair.percentage = 0.25; // Mere strukturelt
}
// Tilføj ekstra tid for specielle forhold
if (geometryData.has_dormers) {
adjustedTasks.special_work.percentage += 0.05;
}
if (geometryData.has_chimneys) {
adjustedTasks.special_work.percentage += 0.03;
}
if (geometryData.has_skylights) {
adjustedTasks.special_work.percentage += 0.03;
}
// Normaliser procenter så de summer til 100%
const totalPercentage = Object.values(adjustedTasks).reduce((sum, task) => sum + task.percentage, 0);
for (const taskKey in adjustedTasks) {
const task = adjustedTasks[taskKey];
const normalizedPercentage = task.percentage / totalPercentage;
const estimatedHours = Math.round(totalHours * normalizedPercentage * 100) / 100;
if (estimatedHours > 0.5) { // Kun inkluder opgaver med mindst 30 min
breakdown.push({
task: task.name,
estimatedHours: estimatedHours,
percentage: Math.round(normalizedPercentage * 100)
});
}
}
return breakdown;
}
// Gem arbejdstimer data
async saveProjectLabor(projectId, laborData) {
try {
const {
carpenterCount,
totalWorkHours,
notes,
customBreakdown
} = laborData;
// Beregn timer per tømrer
const hoursPerCarpenter = this.calculateHoursPerCarpenter(totalWorkHours, carpenterCount);
// Beregn samlet omkostning
const totalLaborCost = totalWorkHours * this.HOURLY_RATE;
// Hent geometri data for arbejdsopdelingen
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
let workBreakdown = [];
if (geometryRows.length > 0) {
workBreakdown = customBreakdown || this.createWorkBreakdown(geometryRows[0], totalWorkHours);
}
const query = `
INSERT INTO project_labor (
project_id, carpenter_count, estimated_hours_per_carpenter,
total_work_hours, hourly_rate, total_labor_cost, work_breakdown, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
carpenter_count = VALUES(carpenter_count),
estimated_hours_per_carpenter = VALUES(estimated_hours_per_carpenter),
total_work_hours = VALUES(total_work_hours),
hourly_rate = VALUES(hourly_rate),
total_labor_cost = VALUES(total_labor_cost),
work_breakdown = VALUES(work_breakdown),
notes = VALUES(notes),
updated_at = CURRENT_TIMESTAMP
`;
const [result] = await this.db.pool.execute(query, [
projectId,
carpenterCount,
hoursPerCarpenter,
totalWorkHours,
this.HOURLY_RATE,
totalLaborCost,
JSON.stringify(workBreakdown),
notes
]);
logger.info('Project labor saved', {
projectId,
carpenterCount,
totalWorkHours,
totalLaborCost
});
return {
id: result.insertId || result.affectedRows,
carpenterCount,
hoursPerCarpenter,
totalWorkHours,
hourlyRate: this.HOURLY_RATE,
totalLaborCost,
workBreakdown
};
} catch (error) {
logger.error('Error saving project labor:', error);
throw error;
}
}
// Hent arbejdstimer data
async getProjectLabor(projectId) {
try {
const [rows] = await this.db.pool.execute(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
if (rows.length === 0) {
return null;
}
const labor = rows[0];
// Parse work breakdown JSON
let workBreakdown = [];
if (labor.work_breakdown) {
try {
workBreakdown = JSON.parse(labor.work_breakdown);
} catch (e) {
logger.warn('Failed to parse work breakdown JSON:', e);
}
}
return {
...labor,
work_breakdown: workBreakdown
};
} catch (error) {
logger.error('Error getting project labor:', error);
throw error;
}
}
// Estimer timer baseret på geometri (helper metode)
async estimateLaborFromGeometry(projectId) {
try {
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
if (geometryRows.length === 0) {
throw new Error('No geometry data found for project');
}
const geometry = geometryRows[0];
const workDistribution = this.calculateWorkDistribution(
geometry.estimated_work_hours,
geometry.estimated_carpenters
);
return {
suggestedCarpenterCount: geometry.estimated_carpenters,
suggestedTotalHours: geometry.estimated_work_hours,
workDistribution,
complexity: geometry.complexity_factor,
roofType: geometry.roof_type
};
} catch (error) {
logger.error('Error estimating labor from geometry:', error);
throw error;
}
}
// Genberegn omkostninger hvis timer ændres
async recalculateLaborCosts(projectId) {
try {
const labor = await this.getProjectLabor(projectId);
if (!labor) {
throw new Error('No labor data found for project');
}
const newTotalCost = labor.total_work_hours * this.HOURLY_RATE;
const newHoursPerCarpenter = this.calculateHoursPerCarpenter(
labor.total_work_hours,
labor.carpenter_count
);
const query = `
UPDATE project_labor
SET hourly_rate = ?, total_labor_cost = ?, estimated_hours_per_carpenter = ?,
updated_at = CURRENT_TIMESTAMP
WHERE project_id = ?
`;
await this.db.pool.execute(query, [
this.HOURLY_RATE,
newTotalCost,
newHoursPerCarpenter,
projectId
]);
logger.info('Labor costs recalculated', {
projectId,
newTotalCost,
hourlyRate: this.HOURLY_RATE
});
return {
totalLaborCost: newTotalCost,
hourlyRate: this.HOURLY_RATE,
hoursPerCarpenter: newHoursPerCarpenter
};
} catch (error) {
logger.error('Error recalculating labor costs:', error);
throw error;
}
}
}
module.exports = ProjectLaborService;

View File

@@ -0,0 +1,384 @@
const logger = require('../utils/logger');
class ProjectMaterialService {
constructor(databaseService) {
this.db = databaseService;
}
// Tilføj materiale til projekt
async addProjectMaterial(projectId, materialData) {
try {
const {
materialName,
materialCategory,
quantity,
unit,
unitPrice,
supplier,
materialSource = 'manual',
notes
} = materialData;
const totalPrice = quantity * unitPrice;
const query = `
INSERT INTO project_materials (
project_id, material_name, material_category, quantity,
unit, unit_price, total_price, supplier, material_source, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.db.pool.execute(query, [
projectId, materialName, materialCategory, quantity,
unit, unitPrice, totalPrice, supplier, materialSource, notes
]);
logger.info('Project material added', {
projectId,
materialName,
quantity,
totalPrice
});
return {
id: result.insertId,
materialName,
materialCategory,
quantity,
unit,
unitPrice,
totalPrice,
supplier,
materialSource
};
} catch (error) {
logger.error('Error adding project material:', error);
throw error;
}
}
// Hent alle materialer for projekt
async getProjectMaterials(projectId) {
try {
const [rows] = await this.db.pool.execute(
`SELECT * FROM project_materials
WHERE project_id = ?
ORDER BY material_category, material_name`,
[projectId]
);
// Gruppér materialer per kategori
const materialsByCategory = {};
let totalMaterialCost = 0;
rows.forEach(material => {
const category = material.material_category || 'Øvrige';
if (!materialsByCategory[category]) {
materialsByCategory[category] = [];
}
materialsByCategory[category].push(material);
totalMaterialCost += parseFloat(material.total_price);
});
return {
materials: rows,
materialsByCategory,
totalMaterialCost,
materialCount: rows.length
};
} catch (error) {
logger.error('Error getting project materials:', error);
throw error;
}
}
// Opdater materiale
async updateProjectMaterial(materialId, updateData) {
try {
const {
materialName,
materialCategory,
quantity,
unit,
unitPrice,
supplier,
notes
} = updateData;
const totalPrice = quantity * unitPrice;
const query = `
UPDATE project_materials
SET material_name = ?, material_category = ?, quantity = ?,
unit = ?, unit_price = ?, total_price = ?, supplier = ?, notes = ?
WHERE id = ?
`;
const [result] = await this.db.pool.execute(query, [
materialName, materialCategory, quantity, unit, unitPrice,
totalPrice, supplier, notes, materialId
]);
if (result.affectedRows === 0) {
throw new Error('Material not found');
}
logger.info('Project material updated', {
materialId,
materialName,
quantity,
totalPrice
});
return {
id: materialId,
materialName,
materialCategory,
quantity,
unit,
unitPrice,
totalPrice,
supplier
};
} catch (error) {
logger.error('Error updating project material:', error);
throw error;
}
}
// Slet materiale
async deleteProjectMaterial(materialId) {
try {
const [result] = await this.db.pool.execute(
'DELETE FROM project_materials WHERE id = ?',
[materialId]
);
if (result.affectedRows === 0) {
throw new Error('Material not found');
}
logger.info('Project material deleted', { materialId });
return true;
} catch (error) {
logger.error('Error deleting project material:', error);
throw error;
}
}
// Importer materialer fra eksisterende database
async importMaterialsFromDatabase(projectId, searchCriteria) {
try {
const { category, searchTerm, limit = 50 } = searchCriteria;
let query = `
SELECT DISTINCT name, material_category, unit, unit_price, supplier_name
FROM ocr_materials
WHERE 1=1
`;
let params = [];
if (category) {
query += ' AND material_category = ?';
params.push(category);
}
if (searchTerm) {
query += ' AND (name LIKE ? OR supplier_name LIKE ?)';
params.push(`%${searchTerm}%`, `%${searchTerm}%`);
}
query += ' ORDER BY name LIMIT ?';
params.push(limit);
const [rows] = await this.db.pool.execute(query, params);
logger.info('Materials found in database', {
projectId,
foundCount: rows.length,
searchCriteria
});
return rows.map(row => ({
materialName: row.name,
materialCategory: row.material_category,
unit: row.unit,
unitPrice: row.unit_price,
supplier: row.supplier_name,
materialSource: 'database'
}));
} catch (error) {
logger.error('Error importing materials from database:', error);
throw error;
}
}
// Bulk tilføj materialer
async bulkAddMaterials(projectId, materials) {
try {
const addedMaterials = [];
let totalCost = 0;
for (const material of materials) {
const result = await this.addProjectMaterial(projectId, material);
addedMaterials.push(result);
totalCost += result.totalPrice;
}
logger.info('Bulk materials added', {
projectId,
materialCount: addedMaterials.length,
totalCost
});
return {
addedMaterials,
totalCost,
count: addedMaterials.length
};
} catch (error) {
logger.error('Error bulk adding materials:', error);
throw error;
}
}
// Hent material kategorier fra database
async getMaterialCategories() {
try {
const [rows] = await this.db.pool.execute(`
SELECT DISTINCT material_category as category, COUNT(*) as count
FROM ocr_materials
WHERE material_category IS NOT NULL AND material_category != ''
GROUP BY material_category
ORDER BY count DESC, material_category ASC
`);
return rows;
} catch (error) {
logger.error('Error getting material categories:', error);
throw error;
}
}
// Beregn materialeomkostninger for projekt
async calculateMaterialCosts(projectId) {
try {
const [rows] = await this.db.pool.execute(`
SELECT
SUM(total_price) as total_cost,
COUNT(*) as material_count,
material_category,
SUM(total_price) as category_cost
FROM project_materials
WHERE project_id = ?
GROUP BY material_category
`, [projectId]);
const [totalRows] = await this.db.pool.execute(`
SELECT
SUM(total_price) as total_cost,
COUNT(*) as material_count
FROM project_materials
WHERE project_id = ?
`, [projectId]);
const categoryBreakdown = rows.map(row => ({
category: row.material_category || 'Øvrige',
cost: parseFloat(row.category_cost),
materialCount: parseInt(row.material_count)
}));
return {
totalMaterialCost: totalRows[0]?.total_cost ? parseFloat(totalRows[0].total_cost) : 0,
materialCount: totalRows[0]?.material_count ? parseInt(totalRows[0].material_count) : 0,
categoryBreakdown
};
} catch (error) {
logger.error('Error calculating material costs:', error);
throw error;
}
}
// Tag materiale forslag baseret på tag type og areal
async suggestRoofMaterials(projectId) {
try {
// Hent geometri data
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
if (geometryRows.length === 0) {
throw new Error('No geometry data found for project');
}
const geometry = geometryRows[0];
const suggestions = [];
// Basis materialer for tag arbejde
const roofMaterialSuggestions = {
'fladt_tag': [
{ name: 'EPDM tagfolie', unit: 'm²', estimatedQuantity: geometry.total_area * 1.1 },
{ name: 'Tagpap', unit: 'm²', estimatedQuantity: geometry.total_area * 1.1 },
{ name: 'Isolering EPS', unit: 'm²', estimatedQuantity: geometry.total_area },
{ name: 'Træbeton', unit: 'm²', estimatedQuantity: geometry.total_area }
],
'skraat_tag': [
{ name: 'Tagsten', unit: 'm²', estimatedQuantity: geometry.total_area * 1.15 },
{ name: 'Undertag', unit: 'm²', estimatedQuantity: geometry.total_area },
{ name: 'Tagcentraler', unit: 'stk', estimatedQuantity: Math.ceil(geometry.total_area / 10) },
{ name: 'Mineraluld', unit: 'm²', estimatedQuantity: geometry.total_area }
],
'mansard': [
{ name: 'Tagsten', unit: 'm²', estimatedQuantity: geometry.total_area * 1.2 },
{ name: 'Undertag', unit: 'm²', estimatedQuantity: geometry.total_area },
{ name: 'Tagcentraler', unit: 'stk', estimatedQuantity: Math.ceil(geometry.total_area / 8) },
{ name: 'Mineraluld', unit: 'm²', estimatedQuantity: geometry.total_area }
],
'komplekst': [
{ name: 'Premium tagsten', unit: 'm²', estimatedQuantity: geometry.total_area * 1.25 },
{ name: 'Højkvalitets undertag', unit: 'm²', estimatedQuantity: geometry.total_area },
{ name: 'Specialbeslag', unit: 'sæt', estimatedQuantity: Math.ceil(geometry.total_area / 20) },
{ name: 'Mineraluld', unit: 'm²', estimatedQuantity: geometry.total_area }
]
};
const materialList = roofMaterialSuggestions[geometry.roof_type] || roofMaterialSuggestions['skraat_tag'];
// Tilføj ekstra materialer for specielle forhold
if (geometry.has_dormers) {
suggestions.push({
name: 'Kvistmaterialer',
unit: 'sæt',
estimatedQuantity: 1,
category: 'Specialmaterialer'
});
}
if (geometry.has_skylights) {
suggestions.push({
name: 'Ovenlys inddækning',
unit: 'stk',
estimatedQuantity: 2,
category: 'Specialmaterialer'
});
}
// Tilføj basis suggestions
materialList.forEach(material => {
suggestions.push({
...material,
category: 'Tagmaterialer',
estimatedQuantity: Math.ceil(material.estimatedQuantity)
});
});
return suggestions;
} catch (error) {
logger.error('Error suggesting roof materials:', error);
throw error;
}
}
}
module.exports = ProjectMaterialService;

View File

@@ -0,0 +1,619 @@
const logger = require('../utils/logger');
class ProjectQuoteGenerationService {
constructor(databaseService, openaiService) {
this.db = databaseService;
this.openai = openaiService;
}
// Generer tilbud kun med statisk indhold (ingen AI tokens brugt)
async generateStaticQuote(projectId, calculationId, options = {}) {
try {
const quoteData = await this.gatherQuoteData(projectId, calculationId);
const companyInfo = this.getStaticCompanyInfo();
const serviceDescriptions = this.getServiceDescriptions();
// Byg tilbud med kun statisk indhold
const quoteText = this.buildCompleteStaticQuote(companyInfo, serviceDescriptions, quoteData);
// Gem tilbud uden AI omkostninger
const savedQuote = await this.saveGeneratedQuote(
projectId,
calculationId,
quoteText,
0, // ingen tokens brugt
0 // ingen omkostning
);
logger.info('Static quote generated', {
projectId,
calculationId,
method: 'static_template'
});
return {
quoteId: savedQuote.id,
quoteText: quoteText,
tokensUsed: 0,
cost: 0,
method: 'static_template',
projectData: quoteData.project,
calculation: quoteData.calculation
};
} catch (error) {
logger.error('Error generating static quote:', error);
throw error;
}
}
// Vælg relevant ydelsestekst baseret på projekttype
getRelevantServiceDescription(projectDescription, roofType) {
const serviceDescriptions = this.getServiceDescriptions();
const description = projectDescription.toLowerCase();
// Match projekt beskrivelse til relevant ydelse
if (description.includes('tag') || description.includes('lægter') || description.includes('rygning')) {
return serviceDescriptions.tagrenovering;
}
if (description.includes('gulv') || description.includes('floor')) {
return serviceDescriptions.gulvudskiftning;
}
if (description.includes('vindue') || description.includes('dør') || description.includes('door')) {
return serviceDescriptions.vinduer_og_doere;
}
if (description.includes('terrasse') || description.includes('deck')) {
return serviceDescriptions.terrasser;
}
if (description.includes('tilbygning') || description.includes('extension')) {
return serviceDescriptions.tilbygninger;
}
if (description.includes('velux') || description.includes('tagvindue')) {
return serviceDescriptions.tagvinduer;
}
// Default til tagrenovering da det er hovedydelsen
return serviceDescriptions.tagrenovering;
}
// Byg komplet tilbud med kun statisk indhold
buildCompleteStaticQuote(companyInfo, serviceDescriptions, quoteData) {
const { project, calculation, geometry, labor, materialSummary } = quoteData;
const currentDate = new Date().toLocaleDateString('da-DK');
// Vælg relevant ydelsestekst
const relevantServiceDescription = this.getRelevantServiceDescription(
project.project_description,
geometry.roof_type
);
// Format materialer liste
const materialsList = materialSummary.map(cat =>
`${cat.material_category}: ${cat.item_count} stk. - ${cat.category_total} kr`
).join('\n');
return `TILBUD FRA ${companyInfo.firmanavn}
Til: ${project.customer_name}
Projekt: ${project.project_name}
Dato: ${currentDate}
Kære ${project.customer_name},
Tak for din henvendelse vedrørende ${project.project_description}.
${companyInfo.motto} - Dette er kernen i alt, hvad vi laver hos ${companyInfo.firmanavn}.
PROJEKTBESKRIVELSE:
${relevantServiceDescription}
ARBEJDE DER UDFØRES:
• Tag type: ${geometry.roof_type}
• Samlet areal: ${geometry.total_area}
• Arbejdstimer: ${labor.total_work_hours} timer med ${labor.carpenter_count} tømrere
• Timepris: ${labor.hourly_rate} kr/time
MATERIALER:
${materialsList}
${serviceDescriptions.materialer_og_kvalitet}
PRISSPECIFIKATION:
Arbejdsløn: ${calculation.total_labor_cost} kr
Materialer: ${calculation.total_material_cost} kr
Subtotal: ${calculation.subtotal} kr
Overhead (${calculation.overhead_percentage}%): ${calculation.overhead_amount} kr
Fortjeneste (${calculation.profit_percentage}%): ${calculation.profit_amount} kr
Moms (${calculation.vat_percentage}%): ${calculation.vat_amount} kr
SAMLET PRIS: ${calculation.total_incl_vat} kr
VORES LØFTE TIL DIG:
${companyInfo.vaerdier.map(v => `${v}`).join('\n')}
${serviceDescriptions.service_løfte}
GARANTIER OG SERVICE:
${companyInfo.garantier.map(g => `${g}`).join('\n')}
TILBUDDET ER GYLDIGT I 30 DAGE
Med venlig hilsen
${companyInfo.firmanavn}
Kontakt:
Telefon: ${companyInfo.kontakt.telefon}
Email: ${companyInfo.kontakt.email}
Website: ${companyInfo.kontakt.website}
${serviceDescriptions.tagarbejde_generelt}`;
}
// Generer professionelt tilbud baseret på struktureret data
async generateProfessionalQuote(projectId, calculationId, options = {}) {
try {
const {
quoteStyle = 'professional',
includeBreakdown = true,
language = 'danish',
customInstructions = ''
} = options;
// Hent alle nødvendige data
const quoteData = await this.gatherQuoteData(projectId, calculationId);
// Byg minimal prompt til AI
const prompt = this.buildOptimizedPrompt(quoteData, {
quoteStyle,
includeBreakdown,
language,
customInstructions
});
// Send til OpenAI med minimal token forbrug
const aiResult = await this.callOpenAI(prompt);
// Gem tilbud i database
const savedQuote = await this.saveGeneratedQuote(
projectId,
calculationId,
aiResult.quoteText,
aiResult.tokensUsed,
aiResult.cost
);
logger.info('Professional quote generated', {
projectId,
calculationId,
tokensUsed: aiResult.tokensUsed,
cost: aiResult.cost
});
return {
quoteId: savedQuote.id,
quoteText: aiResult.quoteText,
tokensUsed: aiResult.tokensUsed,
cost: aiResult.cost,
projectData: quoteData.project,
calculation: quoteData.calculation
};
} catch (error) {
logger.error('Error generating professional quote:', error);
throw error;
}
}
// Samle alle data til tilbudsgenerering
async gatherQuoteData(projectId, calculationId) {
try {
// Hent projekt info
const [projectRows] = await this.db.pool.execute(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
// Hent beregning
const [calculationRows] = await this.db.pool.execute(
'SELECT * FROM project_calculations WHERE id = ?',
[calculationId]
);
// Hent geometri
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Hent arbejdstimer
const [laborRows] = await this.db.pool.execute(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
// Hent materialer grupperet per kategori
const [materialRows] = await this.db.pool.execute(
`SELECT material_category, COUNT(*) as item_count, SUM(total_price) as category_total
FROM project_materials
WHERE project_id = ?
GROUP BY material_category
ORDER BY category_total DESC`,
[projectId]
);
if (projectRows.length === 0 || calculationRows.length === 0) {
throw new Error('Required project or calculation data not found');
}
return {
project: projectRows[0],
calculation: calculationRows[0],
geometry: geometryRows[0],
labor: laborRows[0],
materialSummary: materialRows
};
} catch (error) {
logger.error('Error gathering quote data:', error);
throw error;
}
}
// Statisk firmainformation som ikke kræver AI tokens
getStaticCompanyInfo() {
return {
firmanavn: "Tømrer- og Snedkermester Mikael Holck",
motto: "Measure once cut thrice",
vaerdier: [
"Præcision og ordentlighed i alt hvad vi laver",
"Tradition og transformation går hånd i hånd",
"Meningsfuldt, ordentligt og bæredygtigt håndværk",
"Vi kommer til tiden og står inde for vores arbejde"
],
kontakt: {
telefon: "XX XX XX XX",
email: "info@3byggetilbud.dk",
website: "www.3byggetilbud.dk"
},
garantier: [
"Gratis tagtjek tilbydes",
"Professionel rådgivning i valg af løsninger",
"Korrekt dokumentation og tryghed",
"Kvalitet der holder i mange år"
]
};
}
// Standard ydelsestekster som kan genbruges
getServiceDescriptions() {
return {
tagrenovering: "Tagrenovering hos os betyder, at du får mere end bare et nyt tag. Vi giver dig professionel rådgivning i valg af den rigtige tagbelægning, så løsningen passer til både husets udtryk, dit budget og holder i mange år.",
tagarbejde_generelt: "Med fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på. Vi vurderer altid tagets generelle tilstand og sikrer byggeteknisk korrekte løsninger.",
materialer_og_kvalitet: "Vi indhenter tilbud fra flere leverandører, så du får den bedste balance mellem pris, kvalitet og holdbarhed. Alle materialer er godkendte og lever op til gældende byggestandarder.",
service_løfte: "Vi leverer altid det aftalte til tiden og med kvaliteten i top. Vores erfaring sikrer en flot og holdbar løsning, der øger både komforten og værdien af dit hjem.",
gulvudskiftning: "Udskiftning af gulve hos os betyder, at du får mere end bare en ny overflade. Vi rådgiver dig i at vælge det rette gulv om det skal være slidstærkt, let at vedligeholde eller skabe en varm atmosfære.",
vinduer_og_doere: "Udskiftning af vinduer og døre hos os betyder, at du får mere end bare en ny ramme i huset. Vi rådgiver dig i valg af træ/alu eller træ/træ vinduer, så du får den løsning, der passer bedst til dit hus og dit behov.",
terrasser: "En terrasse hos os er mere end ekstra kvadratmeter udenfor. Vi rådgiver dig i valg af materialer, om det skal være klassisk træ eller vedligeholdelsesfrie løsninger, så terrassen passer til både husets stil og din hverdag.",
tilbygninger: "En tilbygning giver dit hjem mere plads og et nyt udtryk. Vi rådgiver dig i materialer og løsninger, så tilbygningen passer naturligt til husets stil. Med vores erfaring sikrer vi en flot og holdbar udvidelse.",
tagvinduer: "Nye tagvinduer især Velux hos os betyder, at du får en løsning, der er gennemtænkt både æstetisk og byggeteknisk. Vi rådgiver dig i valg af den rigtige type Velux-vindue, så du får mest muligt lys, energioptimering og komfort."
};
}
// Byg optimeret prompt til AI med statisk information
buildOptimizedPrompt(quoteData, options) {
const { project, calculation, geometry, labor, materialSummary } = quoteData;
const companyInfo = this.getStaticCompanyInfo();
const serviceDescriptions = this.getServiceDescriptions();
// Struktureret data til AI - minimal men komplet
const structuredData = {
projektInfo: {
navn: project.project_name,
kunde: project.customer_name,
beskrivelse: project.project_description
},
tagArbejde: {
type: geometry.roof_type,
areal: `${geometry.total_area}`,
kompleksitet: geometry.complexity_factor
},
arbejdstid: {
tømrere: labor.carpenter_count,
timer: `${labor.total_work_hours} timer`,
timepris: `${labor.hourly_rate} kr/time`,
totalArbejde: `${calculation.total_labor_cost} kr`
},
materialer: {
kategorier: materialSummary.map(cat => ({
kategori: cat.material_category,
antal: cat.item_count,
pris: `${cat.category_total} kr`
})),
totalMaterialer: `${calculation.total_material_cost} kr`
},
økonomi: {
subtotal: `${calculation.subtotal} kr`,
overhead: `${calculation.overhead_amount} kr (${calculation.overhead_percentage}%)`,
fortjeneste: `${calculation.profit_amount} kr (${calculation.profit_percentage}%)`,
moms: `${calculation.vat_amount} kr (${calculation.vat_percentage}%)`,
totalPris: `${calculation.total_incl_vat} kr`
}
};
// Build complete quote with static content
const staticQuoteTemplate = this.buildStaticQuoteTemplate(companyInfo, serviceDescriptions, structuredData);
// Minimal prompt der sparer tokens
const prompt = `Skriv et professionelt tilbud baseret på denne template og data:
PROJEKT DATA:
${JSON.stringify(structuredData, null, 2)}
OPGAVE:
Udfyld følgende tilbuds-template med ovenstående data. Behold al statisk tekst uændret og indsæt kun projekt-specifikke oplysninger hvor angivet:
${staticQuoteTemplate}
Krav:
- Behold ALT statisk firmainformation og beskrivelser
- Indsæt kun projekt-specifikke data (kunde, priser, målinger)
- Maksimalt 50 nye ord udover template
- Brug PRÆCIS de angivne priser
${options.customInstructions ? `Specielle tilpasninger: ${options.customInstructions}` : ''}`;
return prompt;
}
// Byg statisk tilbuds template
buildStaticQuoteTemplate(companyInfo, serviceDescriptions, projectData) {
const currentDate = new Date().toLocaleDateString('da-DK');
return `
TILBUD FRA ${companyInfo.firmanavn}
Til: [KUNDE_NAVN]
Projekt: [PROJEKT_NAVN]
Dato: ${currentDate}
Kære [KUNDE_NAVN],
Tak for din henvendelse vedrørende [PROJEKT_BESKRIVELSE].
${companyInfo.motto} - Dette er kernen i alt, hvad vi laver hos ${companyInfo.firmanavn}.
PROJEKTBESKRIVELSE:
${serviceDescriptions.tagrenovering}
ARBEJDE DER UDFØRES:
- Tag type: [TAG_TYPE]
- Samlet areal: [AREAL] m²
- Arbejdstimer: [TIMER] timer med [TØMRER_ANTAL] tømrere
- Timepris: [TIMEPRIS] kr/time
MATERIALER:
[MATERIAL_KATEGORIER_LISTE]
${serviceDescriptions.materialer_og_kvalitet}
PRISSPECIFIKATION:
Arbejdsløn: [ARBEJDE_PRIS] kr
Materialer: [MATERIAL_PRIS] kr
Subtotal: [SUBTOTAL] kr
Overhead ([OVERHEAD_PROCENT]%): [OVERHEAD_BELØB] kr
Fortjeneste ([PROFIT_PROCENT]%): [PROFIT_BELØB] kr
Moms ([MOMS_PROCENT]%): [MOMS_BELØB] kr
SAMLET PRIS: [TOTAL_PRIS] kr
VORES LØFTE TIL DIG:
${companyInfo.vaerdier.map(v => `${v}`).join('\n')}
${serviceDescriptions.service_løfte}
GARANTIER OG SERVICE:
${companyInfo.garantier.map(g => `${g}`).join('\n')}
TILBUDDET ER GYLDIGT I 30 DAGE
Med venlig hilsen
${companyInfo.firmanavn}
Kontakt:
Telefon: ${companyInfo.kontakt.telefon}
Email: ${companyInfo.kontakt.email}
Website: ${companyInfo.kontakt.website}
${serviceDescriptions.tagarbejde_generelt}
`.trim();
}
// Kald OpenAI med optimeret prompt
async callOpenAI(prompt) {
try {
const completion = await this.openai.openai.chat.completions.create({
model: 'gpt-4o-mini', // Billigere model til struktureret tekst
messages: [
{
role: 'system',
content: 'Du er en erfaren tømrermester der skriver professionelle tilbud. Brug præcis de angivne data og priser.'
},
{
role: 'user',
content: prompt
}
],
max_tokens: 800, // Begrænset da vi ønsker koncis tekst
temperature: 0.3 // Lavere kreativitet for konsistent format
});
const quoteText = completion.choices[0].message.content;
const tokensUsed = completion.usage.total_tokens;
const cost = this.calculateTokenCost(tokensUsed, 'gpt-4o-mini');
// Track token usage
this.openai.trackTokenUsage(completion.usage, cost);
return {
quoteText,
tokensUsed,
cost
};
} catch (error) {
logger.error('Error calling OpenAI for quote generation:', error);
throw error;
}
}
// Beregn token omkostninger (GPT-4o-mini priser)
calculateTokenCost(tokens, model) {
const pricing = {
'gpt-4o-mini': {
input: 0.00015, // $0.15 per 1K tokens
output: 0.0006 // $0.60 per 1K tokens
}
};
const rate = pricing[model] || pricing['gpt-4o-mini'];
// Approximation - anta 50/50 split input/output
return ((tokens / 1000) * (rate.input + rate.output) / 2);
}
// Gem genereret tilbud
async saveGeneratedQuote(projectId, calculationId, quoteText, tokensUsed, cost) {
try {
const query = `
INSERT INTO generated_quotes (
project_id, calculation_id, quote_text, quote_format,
ai_tokens_used, ai_cost, ai_model
) VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.db.pool.execute(query, [
projectId,
calculationId,
quoteText,
'html',
tokensUsed,
cost,
'gpt-4o-mini'
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving generated quote:', error);
throw error;
}
}
// Hent tilbud for projekt
async getProjectQuotes(projectId) {
try {
const [rows] = await this.db.pool.execute(
`SELECT gq.*, pc.total_incl_vat, cp.project_name, cp.customer_name
FROM generated_quotes gq
JOIN project_calculations pc ON gq.calculation_id = pc.id
JOIN customer_projects cp ON gq.project_id = cp.id
WHERE gq.project_id = ?
ORDER BY gq.created_at DESC`,
[projectId]
);
return rows;
} catch (error) {
logger.error('Error getting project quotes:', error);
throw error;
}
}
// Opdater tilbud status (sendt, accepteret, etc.)
async updateQuoteStatus(quoteId, status, sentAt = null) {
try {
let query = 'UPDATE generated_quotes SET quote_status = ?';
let params = [status];
if (sentAt && status === 'sent') {
query += ', sent_at = ?';
params.push(sentAt);
}
query += ' WHERE id = ?';
params.push(quoteId);
const [result] = await this.db.pool.execute(query, params);
if (result.affectedRows === 0) {
throw new Error('Quote not found');
}
logger.info('Quote status updated', { quoteId, status });
return true;
} catch (error) {
logger.error('Error updating quote status:', error);
throw error;
}
}
// Generer revideret tilbud med ændringer
async generateRevisedQuote(originalQuoteId, revisionInstructions) {
try {
// Hent originalt tilbud
const [quoteRows] = await this.db.pool.execute(
'SELECT * FROM generated_quotes WHERE id = ?',
[originalQuoteId]
);
if (quoteRows.length === 0) {
throw new Error('Original quote not found');
}
const originalQuote = quoteRows[0];
// Hent projekt data
const quoteData = await this.gatherQuoteData(
originalQuote.project_id,
originalQuote.calculation_id
);
// Byg revision prompt
const revisionPrompt = `Revidér følgende tilbud baseret på ændringer:
ORIGINALT TILBUD:
${originalQuote.quote_text}
ÆNDRINGER DER SKAL LAVES:
${revisionInstructions}
Lav kun de specificerede ændringer og behold resten af tilbuddet uændret.`;
// Kald OpenAI
const aiResult = await this.callOpenAI(revisionPrompt);
// Gem revideret tilbud
const savedQuote = await this.saveGeneratedQuote(
originalQuote.project_id,
originalQuote.calculation_id,
aiResult.quoteText,
aiResult.tokensUsed,
aiResult.cost
);
return {
quoteId: savedQuote.id,
quoteText: aiResult.quoteText,
tokensUsed: aiResult.tokensUsed,
cost: aiResult.cost
};
} catch (error) {
logger.error('Error generating revised quote:', error);
throw error;
}
}
}
module.exports = ProjectQuoteGenerationService;

View File

@@ -0,0 +1,249 @@
const logger = require('../utils/logger');
class RoofGeometryService {
constructor(databaseService) {
this.db = databaseService;
}
// Beregn tag kompleksitet baseret på type og specielle forhold
calculateComplexityFactor(roofData) {
let factor = 1.0;
// Basis kompleksitet baseret på tagtype
switch (roofData.roofType) {
case 'fladt_tag':
factor = 1.0;
break;
case 'skraat_tag':
factor = 1.2;
break;
case 'mansard':
factor = 1.5;
break;
case 'komplekst':
factor = 1.8;
break;
}
// Tilføj kompleksitet for specielle forhold
if (roofData.hasDormers) factor += 0.3;
if (roofData.hasChimneys) factor += 0.2;
if (roofData.hasSkylights) factor += 0.15;
// Adgangs sværhedsgrad
switch (roofData.accessDifficulty) {
case 'let':
factor += 0.0;
break;
case 'medium':
factor += 0.1;
break;
case 'svær':
factor += 0.3;
break;
}
// Taghældning påvirkning
if (roofData.roofPitch) {
if (roofData.roofPitch > 45) factor += 0.2;
else if (roofData.roofPitch > 30) factor += 0.1;
}
return Math.min(factor, 2.0); // Max kompleksitetsfaktor er 2.0
}
// Estimer arbejdstimer baseret på areal og kompleksitet
estimateWorkHours(totalArea, complexityFactor, roofType) {
// Basis timer per m² for forskellige tagtyper
const baseHoursPerM2 = {
'fladt_tag': 0.8,
'skraat_tag': 1.2,
'mansard': 1.8,
'komplekst': 2.5
};
const baseHours = baseHoursPerM2[roofType] || 1.2;
const estimatedHours = totalArea * baseHours * complexityFactor;
// Minimum 8 timer for ethvert tagprojekt
return Math.max(estimatedHours, 8);
}
// Beregn anbefalet antal tømrere
calculateRecommendedCarpenters(totalHours, complexityFactor) {
// For tag arbejde anbefales typisk 2-4 tømrere afhængig af projekt størrelse
let carpenters = 2; // Standard minimum
if (totalHours > 40) carpenters = 3;
if (totalHours > 80) carpenters = 4;
if (totalHours > 120) carpenters = Math.min(5, Math.ceil(totalHours / 30));
// Komplekse projekter kræver flere erfarne folk
if (complexityFactor > 1.5) {
carpenters = Math.max(carpenters, 3);
}
return carpenters;
}
// Gem tag geometri data
async saveRoofGeometry(projectId, geometryData) {
try {
const {
roofType,
totalArea,
roofPitch,
roofHeight,
lengthMain,
widthMain,
hasDormers = false,
hasChimneys = false,
hasSkylights = false,
accessDifficulty = 'medium',
notes
} = geometryData;
// Beregn kompleksitetsfaktor
const complexityFactor = this.calculateComplexityFactor({
roofType,
hasDormers,
hasChimneys,
hasSkylights,
accessDifficulty,
roofPitch
});
// Estimer arbejdstimer
const estimatedWorkHours = this.estimateWorkHours(totalArea, complexityFactor, roofType);
// Beregn anbefalet antal tømrere
const estimatedCarpenters = this.calculateRecommendedCarpenters(estimatedWorkHours, complexityFactor);
const query = `
INSERT INTO roof_geometry (
project_id, roof_type, total_area, roof_pitch, roof_height,
complexity_factor, length_main, width_main, has_dormers,
has_chimneys, has_skylights, access_difficulty,
estimated_work_hours, estimated_carpenters, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
roof_type = VALUES(roof_type),
total_area = VALUES(total_area),
roof_pitch = VALUES(roof_pitch),
roof_height = VALUES(roof_height),
complexity_factor = VALUES(complexity_factor),
length_main = VALUES(length_main),
width_main = VALUES(width_main),
has_dormers = VALUES(has_dormers),
has_chimneys = VALUES(has_chimneys),
has_skylights = VALUES(has_skylights),
access_difficulty = VALUES(access_difficulty),
estimated_work_hours = VALUES(estimated_work_hours),
estimated_carpenters = VALUES(estimated_carpenters),
notes = VALUES(notes),
updated_at = CURRENT_TIMESTAMP
`;
const [result] = await this.db.pool.execute(query, [
projectId, roofType, totalArea, roofPitch, roofHeight,
complexityFactor, lengthMain, widthMain, hasDormers,
hasChimneys, hasSkylights, accessDifficulty,
estimatedWorkHours, estimatedCarpenters, notes
]);
logger.info('Roof geometry saved', {
projectId,
totalArea,
complexityFactor,
estimatedWorkHours,
estimatedCarpenters
});
return {
id: result.insertId || result.affectedRows,
complexityFactor,
estimatedWorkHours,
estimatedCarpenters
};
} catch (error) {
logger.error('Error saving roof geometry:', error);
throw error;
}
}
// Hent tag geometri for projekt
async getRoofGeometry(projectId) {
try {
const [rows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
return rows[0] || null;
} catch (error) {
logger.error('Error getting roof geometry:', error);
throw error;
}
}
// Genberegn estimater hvis geometri ændres
async recalculateEstimates(projectId) {
try {
const geometry = await this.getRoofGeometry(projectId);
if (!geometry) {
throw new Error('No geometry found for project');
}
// Genberegn baseret på eksisterende data
const complexityFactor = this.calculateComplexityFactor({
roofType: geometry.roof_type,
hasDormers: geometry.has_dormers,
hasChimneys: geometry.has_chimneys,
hasSkylights: geometry.has_skylights,
accessDifficulty: geometry.access_difficulty,
roofPitch: geometry.roof_pitch
});
const estimatedWorkHours = this.estimateWorkHours(
geometry.total_area,
complexityFactor,
geometry.roof_type
);
const estimatedCarpenters = this.calculateRecommendedCarpenters(
estimatedWorkHours,
complexityFactor
);
// Opdater database
const query = `
UPDATE roof_geometry
SET complexity_factor = ?, estimated_work_hours = ?, estimated_carpenters = ?,
updated_at = CURRENT_TIMESTAMP
WHERE project_id = ?
`;
await this.db.pool.execute(query, [
complexityFactor, estimatedWorkHours, estimatedCarpenters, projectId
]);
logger.info('Roof estimates recalculated', {
projectId,
complexityFactor,
estimatedWorkHours,
estimatedCarpenters
});
return {
complexityFactor,
estimatedWorkHours,
estimatedCarpenters
};
} catch (error) {
logger.error('Error recalculating roof estimates:', error);
throw error;
}
}
}
module.exports = RoofGeometryService;

View File

@@ -0,0 +1,21 @@
Arbejdsområder tømrer:
udskiftning af tage:
vores primære fokus område er udskiftning af tage her tager vi hånd om kundes udtjente tag vi rådgiver kunden til at vælge den bedste løsningen her tages der højde for økonomien og levetiden samt det byggetekniske
vi er asbest certificeret og kan derfor også håndtere sanering
Opgaver i sammenarbejde med Rådgivere:
når vi arbejder sammen med rådgivere går vi meget op i kvalitetssikring og general dokumentation for at kunne hjælpe rådgiver mest muligt
Tilbygninger:
vi arbejder med tilbygninger som typisk er i sammenarbejde med byggesagansøgninger som rådgiver står for her udføre vi hvad der er beskrevet i udbuddet
her står vi som hoved entreprenør hvor vi står for alle interessenter af håndværkere
Udskiftning og montering af døre og vinduer:
vi står for vejledning af kunden i valg af den bedste løsningen her tages der højde for økonomien og levetiden samt det byggetekniske
her står vi som hoved entreprenør hvor vi står for alle interessenter af håndværkere
mindre indeventige arbejder
vi tager os af monering af døre og gulve lister mm.

View File

@@ -0,0 +1,51 @@
🎯 Målgruppebeskrivelse
Demografi
Alder: 2555 år
Køn: Blandet
Geografi: Sjælland
Beskæftigelse: Ikke-håndværkere, typisk i mellem- til højtlønnede stillinger
Indkomstniveau: Stabil økonomi, hvor der er plads til at investere i boligforbedringer
Psykografi
Værdier: Går op i kvalitet, pris, godt håndværk og at aftaler bliver overholdt
Livsstil: Blandet, men fælles fokus på at få boligen til at fungere og være tryg
Interesser: Forbedring af boligen flere m², tæt tag, bedre løsninger
Drømme: At få konkrete boligudfordringer løst (fx tæt tag, nyt gulv, renovering), så hjemmet bliver bedre på lang sigt
Adfærd
Informationssøgning: Finder typisk virksomheden via hjemmeside, anbefalinger fra andre kunder, reklamer på biler eller Facebook
Købsfrekvens: Sjældent, da det handler om større projekter
Barrierer: Ofte økonomi (prisen på større renoveringsarbejde)
Købsmotivation
Primært behov: At få løst et konkret problem (fx utæt tag)
Følelser: Tryghed i, at arbejdet bliver gjort ordentligt, og glæde ved et forbedret hjem
Præference: Langsigtede investeringer villige til at betale for holdbare løsninger fremfor hurtige fix
Medieforbrug
Sociale medier: Facebook er det primære
Indhold: Læser blogs og nyhedsbreve relateret til bolig og forbedringer
Andre medier: Ser blandet mellem klassisk TV/streaming og online indhold
Kort persona
“Boligejeren på Sjælland”
2555 år, ikke-håndværker, mellem/høj indkomst. De drømmer om at få deres boligproblemer løst på en pålidelig og langsigtet måde. De søger kvalitet og godt håndværk til en fair pris. De finder typisk virksomheden via anbefalinger, hjemmeside eller synlighed på Facebook og reklamer. De er prisbevidste, men vælger hellere en løsning, der holder mange år.

View File

@@ -0,0 +1,97 @@
1. Som om det ikke var nok
2. Det bedste af det hele
3. Men der er mere
4. Nu har du sikkert
5. For resten …
6. Endnu bedre
7. Gode nyheder
8. Her er årsagen
9. I mellemtiden
10. Ikke desto mindre
11. Hvad med dig?
12. Lad mig uddybe det nærmere
13. Men her hører ligheden også op
14. Intet under
15. Der er ikke noget at sige til
16. På den anden side
17. Men det er ikke det hele
18. Ser du
19. Tænk over det
20. Som det ser ud
21. Men det er kun en del af historien
22. Lyder det ikke fair?
23. Det er ikke noget problem
24. Der er ikke noget at sige til
25. Hvad betyder det for dig?
26. Derudover 27. Og det stopper ikke her
28. Det er her [dit produkt] kommer ind i billedet
29. Det har vist sig
30. Men vent … der er mere
31. Indtil nu
32. Her er årsagen
33. Min oplevelse er
34. Det er sandt
35. Min erfaring er
36. Ingen tvivl om det
37. Sandheden er
38. Desværre
39. Kort sagt
40. Disse er kun et udpluk
41. Pointen er
42. Forkæl dig selv
43. Giv dig selv
44. Du vil sikkert
45. Og det betyder
46. Jeg er overbevist om
47. Det samme gælder
At bide i det sure æble.
At slå to fluer med ét smæk.
At stikke hovedet i busken.
At slå en streg over det.
At løbe med halen mellem benene.
At få kolde fødder.
At være på herrens mark.
At gå i hundene.
At hælde vand ud af ørerne.
At have is i maven.
At brænde sit lys i begge ender.
At sidde med hænderne i skødet.
At kaste håndklædet i ringen.
At skyde papegøjen.
At male fanden på væggen.
At tage tyren ved hornene.
At gå som varmt brød.
At have en finger med i spillet.
At feje for egen dør.
At trække på samme hammel.
At have sommerfugle i maven.
At være på forkant.
At have rent mel i posen.
At komme i vælten.
At være som en fisk i vandet.
At have en kniv for struben.
At have røven i klaskehøjde.
At få blod på tanden.
At gå i stå.
At være højt på strå.
At tage bladet fra munden.
At være på røven.
At have en sort dag.
At gå på nåle.
At gå op i en højere enhed.
At gå op ad bakke.
At være som nat og dag.
At stå på egne ben.
At være ude med riven.
At have ild i rumpetten.
At spille på flere heste.
At være et varmt emne.
At tage det med et gran salt.
At slå bunden ud af noget.
At lægge låg på.
At slå hånden af nogen.
At være i sit es.
At sidde i saksen.
At trække det længste strå.
At springe over, hvor gærdet er lavest.

View File

@@ -0,0 +1,14 @@
Why (Hvorfor):
"Vi skaber et meningsfyldt og ordentligt håndværk så transformation og tradition går hånd i hånd med bæredygtighed."
Mikael Holck
3byggetilbud.dk
How (Hvordan):
"Vi brænder for detaljen og glæden ved håndværket. Sammen skaber vi resultater som kunden glædes ved mange år fremover."
Mikael Holck
3byggetilbud.dk
What (Hvad):
"Vi driver en sund og professionel tømrer- og snedkervirksomhed, som yder ordentlighed og tryghed til vores kunder."
Mikael Holck
3byggetilbud.dk

View File

@@ -0,0 +1,31 @@
Vores DNA Tømrer- og Snedkermester Mikael Holck
Hos Tømrer- og Snedkermester Mikael Holck er præcision og ordentlighed kernen i alt, hvad vi laver. Som vi siger: “Measure once cut thrice.” Det handler om at gøre arbejdet rigtigt hver gang og levere resultater, vi kan være stolte af.
Hvorfor vi gør det
Vi tror på, at håndværk skal være meningsfuldt, ordentligt og bæredygtigt. Tradition og transformation går hånd i hånd, når vi skaber løsninger, der holder både for mennesker og miljø.
Hvordan vi gør det
Vi går op i detaljen, glæden ved godt håndværk og i at skabe resultater, vores kunder kan glæde sig over i mange år. Pålidelighed, korrekt dokumentation og en høj grad af professionalisme er altid en naturlig del af vores proces.
Hvad vi tilbyder
Vi driver en sund og professionel virksomhed med to stærke afdelinger:
Tømrer- og snedkerafdelingen
Murerafdelingen
Vi hjælper både private og erhverv med alt fra små opgaver til større projekter altid leveret til tiden og med kvaliteten i top.
Det, der gør os særlige
Vi kommer til tiden og står inde for vores arbejde.
Vi sikrer korrekt dokumentation, så vores kunder altid kan føle sig trygge.
Vi kombinerer klassisk håndværk med moderne løsninger og bæredygtighed.
For os handler det ikke kun om at bygge men om at skabe værdi, tillid og tryghed gennem ordentligt håndværk.

View File

@@ -0,0 +1,35 @@
SEO Tekst
Audience: Teksten skal målrettes virksomhedens målgruppe angivet i [målgrupper.txt].
Behavior: Teksten skal få læserne til at engagere sig og tage specifikke handlinger, som at købe i webshoppen eller tilmelde sig nyhedsbrevet.
Role: Du er en ultra professionel SEO copywriter.
Task: Skriv en landing page tekst om et brugerdefineret emne, hvis dette ikke er angivet, skal du spørge efter det. Optimer teksten til SEO med fokus på de vigtigste nøgleord. Sikr, at teksten er 100% semantisk perfekt i forhold til det primære emne.
Content Requirements:
Inkluder vigtige fordele for læseren.
Byg på informationer fra [virksomhedsinfo.txt].
Brug storytelling strukturer.
Inkluder en FAQ sektion relevant for emnet.
Brug flowformuleringer fra [flow.txt].
Structure: Start med en engagerende overskrift og et kort afsnit på 80-110 ord. Herefter skriv afsnit på 200-400 ord, evaluer hver sektion og få brugerens godkendelse, før du fortsætter.
SoMe post
Sociale Medier Indlæg
Audience: Indlæggene skal målrettes virksomhedens målgruppe angivet i [målgrupper.txt].
Behavior: Indlæggene skal inspirere til engagement og delinger.
Role: Du er en social media guru med dyb forståelse for målgruppen.
Task: Skriv kreative og fængslende sociale medier indlæg om et brugerdefineret emne, hvis dette ikke er angivet, skal du spørge efter det.
Content Requirements:
Væk følelser og engagement.
Brug storytelling teknikker.
Inkluder 4 relevante hashtags.
Structure: Skriv korte og præcise indlæg med høj engagementsfaktor.
Nyhedsbreve
Audience: Nyhedsbrevene skal målrettes virksomhedens målgruppe angivet i [målgrupper.txt].
Behavior: De skal øge åbninger og klikrater.
Role: Du er en ekspert i e-mail marketing.
Task: Skriv engagerende nyhedsbreve om et brugerdefineret emne, hvis dette ikke er angivet, skal du spørge efter det.
Content Requirements:
Byg på storytelling.
Inkluder stærke call-to-actions.
Sørg for et balanceret flow i teksten.
Structure: Start med en iøjnefaldende overskrift og introduktion, fortsæt med informative og engagerende afsnit.

View File

@@ -0,0 +1,9 @@
Udskiftning af B7 tag
Beskrivelse:
Lægter demonteres, og nye godkendte c18 taglægter monteres. Tagflader afdækkes med presenninger i perioden inden nye plader monteres. Ventileret tagfod og rygning udføres. Vindkasser for beskyttelse af isolering monteres. Nye sorte B7 sort/blå Cembrit tagplader leveres og monteres efter forskrifter. Rygningsplader leveres og monteres som ventileret med DE flex profiler.
Priser:
Ventileret rygning 639 kr. pr. meter
Montering af B7 plader inkl. lægter 822 kr. pr m2

View File

@@ -0,0 +1,29 @@
Ydelser Tømrer
Tagrenovering
Vi tilbyder gratis tagtjek.
Tagrenovering hos os betyder, at du får mere end bare et nyt tag. Vi giver dig professionel rådgivning i valg af den rigtige tagbelægning, så løsningen passer til både husets udtryk, dit budget og holder i mange år. Med fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på.
Udskiftning af gulve
Udskiftning af gulve hos os betyder, at du får mere end bare en ny overflade. Vi rådgiver dig i at vælge det rette gulv om det skal være slidstærkt, let at vedligeholde eller skabe en varm atmosfære. Ønsker du gulvvarme, kan vi stå for hele processen, så du får en samlet løsning, der giver komfort, værdi og lang holdbarhed.
Udskiftning af vinduer og døre
Udskiftning af vinduer og døre hos os betyder, at du får mere end bare en ny ramme i huset. Vi rådgiver dig i valg af træ/alu eller træ/træ vinduer, så du får den løsning, der passer bedst til dit hus og dit behov. For at sikre den bedste økonomi indhenter vi tilbud fra tre producenter, så du får en optimal kombination af kvalitet, pris og holdbarhed.
Montering af indvendige døre
Montering af indvendige døre hos os betyder, at du får mere end bare en dør sat i. Vi rådgiver dig i valg af glatte døre, fyldningsdøre eller lydisolerende løsninger, så de matcher både stil og funktion. Med fokus på byggeteknisk præcision sikrer vi, at dørene står snorlige, fungerer perfekt og holder i mange år.
Etablering af terrasser
En terrasse hos os er mere end ekstra kvadratmeter udenfor. Vi rådgiver dig i valg af materialer, om det skal være klassisk træ eller vedligeholdelsesfrie løsninger, så terrassen passer til både husets stil og din hverdag. Vi indhenter tilbud fra flere leverandører, så du får den bedste balance mellem pris, kvalitet og holdbarhed. Og vigtigst af alt vi leverer altid det aftalte til tiden.
Opsætning af lofter og systemlofter
Opsætning af lofter og systemlofter hos os betyder, at du får mere end bare et nyt loft. Vi rådgiver dig i valg af den rigtige løsning om det skal være et klassisk træloft, gips eller et moderne systemloft med bedre akustik og et flot udtryk. Resultatet bliver både funktionelt og æstetisk.
Etablering af tagvinduer (Velux)
Nye tagvinduer især Velux hos os betyder, at du får en løsning, der er gennemtænkt både æstetisk og byggeteknisk. Vi rådgiver dig i valg af den rigtige type Velux-vindue, så du får mest muligt lys, energioptimering og komfort. Når vi er på taget, vurderer vi samtidig tagets generelle tilstand, så du får fuld tryghed og et resultat, der holder i mange år.
Montering af fodpaneler
Fodpaneler giver rummet den sidste finish. Vi hjælper dig med at vælge den rigtige stil klassisk eller moderne og sørger for en flot og præcis montering, så dit hjem får et elegant og gennemført udtryk.
Tilbygninger
En tilbygning giver dit hjem mere plads og et nyt udtryk. Vi rådgiver dig i materialer og løsninger, så tilbygningen passer naturligt til husets stil. Med vores erfaring sikrer vi en flot og holdbar udvidelse, der både øger komforten og værdien af dit hjem. Vi kan også være behjælpelige med at finde en rådgiver, der kan stå for byggeansøgningen, så processen bliver nemmere for dig.

View File

View File

@@ -8,6 +8,9 @@ const PORT = process.env.PORT || 4030;
// Load environment variables
require('dotenv').config();
// Import logger
const logger = require('./backend/src/utils/logger');
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
@@ -42,11 +45,11 @@ const initializeBackend = async () => {
// Initialize database
await databaseService.initialize();
console.log('Database initialized successfully');
logger.info('Database initialized successfully');
return true;
} catch (error) {
console.error('Failed to initialize backend:', error);
logger.error('Failed to initialize backend:', error);
return false;
}
};
@@ -78,7 +81,7 @@ app.get('/api/quotes/openai/stats', async (req, res) => {
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error getting OpenAI stats:', error);
logger.error('Error getting OpenAI stats:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke hente token statistik',
@@ -111,7 +114,7 @@ app.post('/api/quotes/openai/stats/update', async (req, res) => {
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error updating OpenAI stats:', error);
logger.error('Error updating OpenAI stats:', error);
res.status(500).json({
success: false,
error: 'Kunne ikke opdatere budget data',
@@ -141,7 +144,10 @@ app.post('/api/quotes/generate', async (req, res) => {
});
}
console.log('Generating quote', { description, area, projectType });
try {
logger.info('Generating quote', { description, area, projectType });
// Check if services are initialized
// Generate quote using OpenAI
const result = await openaiService.generateQuote({
@@ -170,7 +176,7 @@ app.post('/api/quotes/generate', async (req, res) => {
});
} catch (error) {
console.error('Error generating quote:', error);
logger.error('Error generating quote:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved generering af tilbud'
@@ -211,7 +217,7 @@ app.post('/api/quotes/carpenter-hours', async (req, res) => {
});
} catch (error) {
console.error('Error calculating carpenter hours:', error);
logger.error('Error calculating carpenter hours:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved beregning af tømrer timer'
@@ -250,7 +256,7 @@ app.post('/api/quotes/save-carpenter-calculation', async (req, res) => {
});
} catch (error) {
console.error('Error saving carpenter calculation:', error);
logger.error('Error saving carpenter calculation:', error);
res.status(500).json({
success: false,
error: error.message || 'Der opstod en fejl ved gemning af beregning'
@@ -279,14 +285,14 @@ app.post('/api/auth/login', (req, res) => {
// Check credentials
if (username === VALID_CREDENTIALS.username && password === VALID_CREDENTIALS.password) {
console.log('Successful login attempt:', { username, ip: req.ip });
logger.info('Successful login attempt:', { username, ip: req.ip });
res.json({
success: true,
message: 'Login successful'
});
} else {
console.log('Failed login attempt:', { username, ip: req.ip });
logger.warn('Failed login attempt:', { username, ip: req.ip });
res.status(401).json({
success: false,
@@ -294,7 +300,7 @@ app.post('/api/auth/login', (req, res) => {
});
}
} catch (error) {
console.error('Login error:', error);
logger.error('Login error:', error);
res.status(500).json({
success: false,
error: 'Der opstod en fejl'
@@ -323,7 +329,7 @@ app.post('/api/web-prices/search', async (req, res) => {
});
}
console.log(`Enhanced price search for: ${materialType}`);
logger.info(`Enhanced price search for: ${materialType}`);
// Use enhanced search with fallback to estimation
const results = await webPriceService.searchMaterialPricesWithFallback(materialType, specifications);
@@ -337,9 +343,9 @@ app.post('/api/web-prices/search', async (req, res) => {
[bestResult.name, materialType, bestResult.price, bestResult.unit, bestResult.source, bestResult.confidence, 1, bestResult.price, bestResult.description || '', bestResult.notes || '']
);
console.log(`Saved price to database: ${bestResult.name} - ${bestResult.price} DKK`);
logger.info(`Saved price to database: ${bestResult.name} - ${bestResult.price} DKK`);
} catch (saveError) {
console.warn('Failed to save price to database:', saveError.message);
logger.warn('Failed to save price to database:', saveError.message);
}
}
@@ -357,7 +363,7 @@ app.post('/api/web-prices/search', async (req, res) => {
});
} catch (error) {
console.error('Error in web price search:', error);
logger.error('Error in web price search:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning efter webpriser',
@@ -370,7 +376,7 @@ app.post('/api/web-prices/carpenter-rates', async (req, res) => {
try {
const { workType = 'generel tømrerarbejde', saveToDatabase = false } = req.body;
console.log(`Web search for carpenter rates: ${workType}`);
logger.info(`Web search for carpenter rates: ${workType}`);
const results = await webPriceService.getTomrerRates(workType);
@@ -383,9 +389,9 @@ app.post('/api/web-prices/carpenter-rates', async (req, res) => {
[workType, `Web-hentet tømrersats - ${rate.notes}`, rate.hourlyRate, 0, 0, 0, 0, 0, 0, 1.0]
);
console.log(`Saved carpenter rate to database: ${workType} - ${rate.hourlyRate} DKK/time`);
logger.info(`Saved carpenter rate to database: ${workType} - ${rate.hourlyRate} DKK/time`);
} catch (saveError) {
console.warn('Failed to save carpenter rate to database:', saveError.message);
logger.warn('Failed to save carpenter rate to database:', saveError.message);
}
}
@@ -398,7 +404,7 @@ app.post('/api/web-prices/carpenter-rates', async (req, res) => {
});
} catch (error) {
console.error('Error in carpenter rates search:', error);
logger.error('Error in carpenter rates search:', error);
res.status(500).json({
success: false,
error: 'Fejl ved søgning efter tømrersatser',
@@ -429,7 +435,9 @@ app.get('/api/web-prices/suggestions/:projectType', async (req, res) => {
});
// Price import endpoints
app.post('/api/prices/import', upload.single('priceFile'), async (req, res) => {
app.post('/api/pricing/import', upload.single('file'), async (req, res) => {
let uploadedFilePath = null;
try {
if (!req.file) {
return res.status(400).json({
@@ -438,11 +446,10 @@ app.post('/api/prices/import', upload.single('priceFile'), async (req, res) => {
});
}
const PriceImportService = require('./backend/src/services/priceImportService').PriceImportService;
const importService = new PriceImportService(databaseService);
uploadedFilePath = req.file.path;
// Parse CSV file
const parseResult = await importService.importPricesFromCSV(req.file.path);
const parseResult = await importService.importPricesFromCSV(uploadedFilePath);
if (parseResult.results.length === 0) {
return res.status(400).json({
@@ -455,9 +462,6 @@ app.post('/api/prices/import', upload.single('priceFile'), async (req, res) => {
// Insert to database
const insertResult = await importService.bulkInsertPrices(parseResult.results);
// Clean up uploaded file
require('fs').unlinkSync(req.file.path);
res.json({
success: true,
message: `${insertResult.inserted.length} priser importeret succesfuldt`,
@@ -467,12 +471,22 @@ app.post('/api/prices/import', upload.single('priceFile'), async (req, res) => {
});
} catch (err) {
console.error('Price import error:', err);
logger.error('Price import error:', err);
res.status(500).json({
success: false,
error: 'Fejl ved import af priser',
details: err.message
});
} finally {
// Always clean up uploaded file
if (uploadedFilePath) {
try {
require('fs').unlinkSync(uploadedFilePath);
logger.debug('Uploaded file cleaned up:', uploadedFilePath);
} catch (cleanupError) {
logger.warn('Failed to clean up uploaded file:', cleanupError.message);
}
}
}
});
@@ -622,8 +636,36 @@ app.post('/api/categories/labor', async (req, res) => {
});
}
// For now, just log the data instead of saving to database
console.log('New labor price submission:', {
// Convert difficulty to multiplier
const difficultyMultiplier = difficulty === 'høj' ? 1.4 :
difficulty === 'medium' ? 1.2 : 1.0;
// Calculate labor cost
const calculatedLaborCost = (totalHours || 0) * parseFloat(hourlyRate);
// Save to database
const result = await databaseService.query(
`INSERT INTO project_quotes
(project_type, notes, labor_rate, project_area, labor_cost, difficulty_multiplier,
material_cost, overhead_cost, total_excl_vat, vat_amount, total_incl_vat, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
projectType,
description,
parseFloat(hourlyRate),
area ? parseFloat(area) : null,
calculatedLaborCost,
difficultyMultiplier,
0, // material_cost
0, // overhead_cost
calculatedLaborCost, // total_excl_vat
calculatedLaborCost * 0.25, // vat_amount (25%)
calculatedLaborCost * 1.25 // total_incl_vat
]
);
console.log('New labor price saved to database:', {
id: result.insertId,
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
@@ -636,7 +678,19 @@ app.post('/api/categories/labor', async (req, res) => {
res.json({
success: true,
message: 'Arbejdspris tilføjet succesfuldt (demo mode)'
message: 'Arbejdspris tilføjet succesfuldt',
laborEntry: {
id: result.insertId,
projectType,
description,
hourlyRate: parseFloat(hourlyRate),
area: area ? parseFloat(area) : null,
totalHours: totalHours ? parseFloat(totalHours) : null,
difficulty: difficulty || 'normal',
location: location || null,
notes: notes || null,
created_at: new Date().toISOString()
}
});
} catch (error) {
console.error('Error adding labor price:', error);
@@ -682,9 +736,58 @@ app.post('/api/categories/materials', async (req, res) => {
notes: notes || null
});
// Create OCR document entry for the supplier
const docResult = await databaseService.query(
`INSERT INTO ocr_documents
(filename, supplier_name, document_date, created_at)
VALUES (?, ?, NOW(), NOW())`,
[`manual_entry_${Date.now()}.json`, supplierName || 'Manuel indtastning']
);
// Save material to database
const materialResult = await databaseService.query(
`INSERT INTO ocr_materials
(name, material_category, description, unit_price, unit, sku, confidence, ocr_document_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
name,
category,
subcategory || null,
parseFloat(price),
unit || 'stk',
sku || null,
1.0, // Full confidence for manual entries
docResult.insertId
]
);
console.log('New material price saved to database:', {
id: materialResult.insertId,
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplierName: supplierName || null,
sku: sku || null
});
res.json({
success: true,
message: 'Materialepris tilføjet succesfuldt (demo mode)'
message: 'Materialepris tilføjet succesfuldt',
materialEntry: {
id: materialResult.insertId,
name,
category,
subcategory: subcategory || null,
price: parseFloat(price),
unit: unit || 'stk',
supplier_name: supplierName || null,
sku: sku || null,
description: description || null,
notes: notes || null,
created_at: new Date().toISOString()
}
});
} catch (error) {
console.error('Error adding material price:', error);
@@ -697,69 +800,6 @@ app.post('/api/categories/materials', async (req, res) => {
// Update labor price
// Create new labor price
app.post('/api/labor-price', async (req, res) => {
try {
const { project_type, description, labor_rate } = req.body;
const result = await databaseService.query(
'INSERT INTO project_quotes (project_type, notes, labor_rate, labor_cost, material_cost, overhead_cost, total_excl_vat, vat_amount, total_incl_vat, difficulty_multiplier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[project_type, description, labor_rate, 0, 0, 0, 0, 0, 0, 1.0]
);
res.json({
success: true,
message: 'Labor price created successfully',
id: result.insertId
});
} catch (error) {
console.error('Error creating labor price:', error);
res.status(500).json({ success: false, message: 'Error creating labor price' });
}
});
// Update labor price
app.put('/api/labor-price/:id', async (req, res) => {
try {
const { id } = req.params;
const { description, labor_rate } = req.body;
const result = await databaseService.query(
'UPDATE project_quotes SET notes = ?, labor_rate = ? WHERE id = ?',
[description, labor_rate, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Labor price not found' });
}
res.json({ success: true, message: 'Labor price updated successfully' });
} catch (error) {
console.error('Error updating labor price:', error);
res.status(500).json({ success: false, message: 'Error updating labor price' });
}
});
// Delete labor price
app.delete('/api/labor-price/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await databaseService.query(
'DELETE FROM project_quotes WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: 'Labor price not found' });
}
res.json({ success: true, message: 'Labor price deleted successfully' });
} catch (error) {
console.error('Error deleting labor price:', error);
res.status(500).json({ success: false, message: 'Error deleting labor price' });
}
});
// Delete labor price
app.delete('/api/categories/labor/:id', async (req, res) => {
try {
@@ -995,9 +1035,24 @@ app.delete('/api/categories/materials/:id', async (req, res) => {
console.log('Delete material price:', id);
// Delete from ocr_materials table
const result = await databaseService.query(
'DELETE FROM ocr_materials WHERE id = ?',
[parseInt(id)]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Materialepris ikke fundet'
});
}
console.log('Material price deleted successfully:', id);
res.json({
success: true,
message: 'Materialepris slettet succesfuldt (demo mode)'
message: 'Materialepris slettet succesfuldt'
});
} catch (error) {
console.error('Error deleting material price:', error);
@@ -1182,6 +1237,9 @@ app.get('/health', (req, res) => {
const frontendBuildPath = '/home/alex/git/tilbudgivern/frontend/build';
app.use(express.static(frontendBuildPath));
// Customer Projects API Routes
app.use('/api/customer', require('./backend/src/routes/customerProjects'));
// Handle React Router - serve index.html for all non-API routes
app.get('*', (req, res) => {
const indexPath = '/home/alex/git/tilbudgivern/frontend/build/index.html';