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:
178
backend/sql/customer_project_system.sql
Normal file
178
backend/sql/customer_project_system.sql
Normal 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)
|
||||
);
|
||||
818
backend/src/routes/customerProjects.js
Normal file
818
backend/src/routes/customerProjects.js
Normal 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;
|
||||
213
backend/src/services/customerProjectService.js
Normal file
213
backend/src/services/customerProjectService.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
403
backend/src/services/projectCalculationService.js
Normal file
403
backend/src/services/projectCalculationService.js
Normal 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;
|
||||
276
backend/src/services/projectLaborService.js
Normal file
276
backend/src/services/projectLaborService.js
Normal 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;
|
||||
384
backend/src/services/projectMaterialService.js
Normal file
384
backend/src/services/projectMaterialService.js
Normal 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;
|
||||
619
backend/src/services/projectQuoteGenerationService.js
Normal file
619
backend/src/services/projectQuoteGenerationService.js
Normal 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} m²
|
||||
• 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} m²`,
|
||||
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;
|
||||
249
backend/src/services/roofGeometryService.js
Normal file
249
backend/src/services/roofGeometryService.js
Normal 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;
|
||||
21
frontend/firmadata/firmaprofil/Arbejdsområder tømrer.txt
Normal file
21
frontend/firmadata/firmaprofil/Arbejdsområder tømrer.txt
Normal 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.
|
||||
|
||||
|
||||
51
frontend/firmadata/firmaprofil/Målgrupper.txt
Normal file
51
frontend/firmadata/firmaprofil/Målgrupper.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
🎯 Målgruppebeskrivelse
|
||||
Demografi
|
||||
|
||||
Alder: 25–55 å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”
|
||||
25–55 å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.
|
||||
97
frontend/firmadata/firmaprofil/Ordsprog.txt
Normal file
97
frontend/firmadata/firmaprofil/Ordsprog.txt
Normal 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.
|
||||
14
frontend/firmadata/firmaprofil/Strategens DNA.txt
Normal file
14
frontend/firmadata/firmaprofil/Strategens DNA.txt
Normal 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
|
||||
31
frontend/firmadata/firmaprofil/Virksomhedsinformation.txt
Normal file
31
frontend/firmadata/firmaprofil/Virksomhedsinformation.txt
Normal 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.
|
||||
@@ -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.
|
||||
9
frontend/firmadata/udregning/B7_tag.txt
Normal file
9
frontend/firmadata/udregning/B7_tag.txt
Normal 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
|
||||
29
frontend/firmadata/ydelser/Ydelser – Tømrer.txt
Normal file
29
frontend/firmadata/ydelser/Ydelser – Tømrer.txt
Normal 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.
|
||||
0
tilbudgivern-backend.service
Normal file
0
tilbudgivern-backend.service
Normal 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';
|
||||
|
||||
Reference in New Issue
Block a user