Add project data, quotes, and real project details for Kunda a/s
- Created test_project_data.json with project details including customer information, project description, and counts for geometry, labor, materials, and quotes. - Added test_quote_data.json containing company information, project details, geometry, labor, materials, and calculation summary. - Introduced test_real_project.json with comprehensive project data including labor, materials, and detailed calculations. - Generated updated_real_project_quote.pdf reflecting the latest project quote details.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# Git Large File Problem - LØST ✅
|
||||
|
||||
## Problem
|
||||
Git push fejlede med fejl: **"File core is 196.59 MB; this exceeds GitHub's file size limit of 100.00 MB"**
|
||||
|
||||
```
|
||||
remote: error: File core is 196.59 MB; this exceeds GitHub's file size limit of 100.00 MB
|
||||
remote: error: GH001: Large files detected. You may want to try Git Large File Storage
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
En **core dump fil** (196MB) var blevet tilføjet til repository i commit `779bb30`. Core dumps genereres typisk når programmer crasher og skal ikke være i version control.
|
||||
|
||||
## Løsning Udført
|
||||
|
||||
### 1. 🔍 Identificeret Problem
|
||||
```bash
|
||||
find /home/alex/git/tilbudgivern -name "core" -type f -exec ls -lh {} \;
|
||||
# Result: -rw------- 1 alex alex 197M Sep 17 09:37 /home/alex/git/tilbudgivern/core
|
||||
```
|
||||
|
||||
### 2. 🗑️ Fjernet Core Fil
|
||||
```bash
|
||||
rm core
|
||||
```
|
||||
|
||||
### 3. 📝 Opdateret .gitignore
|
||||
Tilføjet core dump patterns til `.gitignore`:
|
||||
```gitignore
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
*.core
|
||||
*.dump
|
||||
```
|
||||
|
||||
### 4. 🧹 Renset Git Historie
|
||||
Brugt `git filter-branch` til at fjerne core filen fra hele git historikken:
|
||||
```bash
|
||||
git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch core' --prune-empty --tag-name-filter cat -- --all
|
||||
```
|
||||
|
||||
### 5. 🚮 Garbage Collection
|
||||
Renset git repository og fjernet alle spor:
|
||||
```bash
|
||||
rm -rf .git/refs/original/
|
||||
git reflog expire --expire=now --all
|
||||
git gc --prune=now --aggressive
|
||||
```
|
||||
|
||||
### 6. ⬆️ Force Push
|
||||
Push'et den rensede historie til GitHub:
|
||||
```bash
|
||||
git push origin main --force
|
||||
```
|
||||
|
||||
## Resultat: ✅ SUCCESS
|
||||
|
||||
### Før Cleanup:
|
||||
- **Repository størrelse**: >200MB (med core fil)
|
||||
- **Git push**: ❌ FEJL - "File size limit exceeded"
|
||||
|
||||
### Efter Cleanup:
|
||||
- **Repository størrelse**: 3.6MB
|
||||
- **Git push**: ✅ SUCCESS
|
||||
- **Core filer**: Permanent fjernet fra historie
|
||||
|
||||
## Preventive Tiltag
|
||||
|
||||
### 📋 .gitignore Opdateret
|
||||
Tilføjet patterns til at forhindre fremtidige core dump filer:
|
||||
```gitignore
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
*.core
|
||||
*.dump
|
||||
```
|
||||
|
||||
### 🛡️ Beskyttelse Mod Fremtidige Issues
|
||||
- Core dump filer vil automatisk blive ignoreret
|
||||
- PM2 crash dumps vil ikke længere blive tracked
|
||||
- Repository forbliver rent og let
|
||||
|
||||
## Lektioner Lært
|
||||
|
||||
### 🚨 Core Dumps
|
||||
- Genereres ved program crashes (fx Node.js PM2 fejl)
|
||||
- Skal ALDRIG committes til version control
|
||||
- Kan være meget store (100MB+)
|
||||
|
||||
### 🧹 Git Cleanup
|
||||
- `git filter-branch` kan fjerne filer fra hele historikken
|
||||
- `--force` push nødvendigt efter historie ændring
|
||||
- Altid cleanup refs og garbage collect efter filter-branch
|
||||
|
||||
### 📁 .gitignore Best Practices
|
||||
- Inkluder core dump patterns
|
||||
- Overvej PM2 og process dump filer
|
||||
- Regulær review af `.gitignore` for nye file types
|
||||
|
||||
## Status: 🎉 **PROBLEM LØST**
|
||||
|
||||
- ✅ Core fil fjernet fra repository
|
||||
- ✅ Git historie renset
|
||||
- ✅ Push til GitHub successful
|
||||
- ✅ .gitignore opdateret for fremtiden
|
||||
- ✅ Repository størrelse optimeret (3.6MB)
|
||||
|
||||
**Alle nye commits og push'es vil nu fungere normalt!**
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PDF Generator for Tilbudgivern
|
||||
Uses ReportLab for reliable PDF generation on ARM/Raspberry Pi systems
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import mm, cm
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
class TilbudgivernPDFGenerator:
|
||||
def __init__(self):
|
||||
self.styles = getSampleStyleSheet()
|
||||
self.setup_custom_styles()
|
||||
|
||||
def setup_custom_styles(self):
|
||||
"""Setup custom styles for the PDF"""
|
||||
# Header style
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='CompanyHeader',
|
||||
parent=self.styles['Heading1'],
|
||||
fontSize=16,
|
||||
spaceAfter=6,
|
||||
textColor=colors.darkblue,
|
||||
alignment=TA_CENTER
|
||||
))
|
||||
|
||||
# Quote header style
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='QuoteHeader',
|
||||
parent=self.styles['Heading2'],
|
||||
fontSize=14,
|
||||
spaceAfter=12,
|
||||
textColor=colors.black,
|
||||
alignment=TA_LEFT
|
||||
))
|
||||
|
||||
# Body text with spacing
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='BodySpaced',
|
||||
parent=self.styles['Normal'],
|
||||
fontSize=10,
|
||||
spaceAfter=6,
|
||||
leading=12
|
||||
))
|
||||
|
||||
# Price text
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='PriceText',
|
||||
parent=self.styles['Normal'],
|
||||
fontSize=11,
|
||||
spaceAfter=4,
|
||||
fontName='Helvetica-Bold'
|
||||
))
|
||||
|
||||
def load_logo(self, logo_path):
|
||||
"""Load company logo if available"""
|
||||
try:
|
||||
if os.path.exists(logo_path):
|
||||
return Image(logo_path, width=60*mm, height=30*mm)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load logo from {logo_path}: {e}")
|
||||
return None
|
||||
|
||||
def safe_float(self, value, default=0):
|
||||
"""Safely convert value to float"""
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def safe_format_price(self, value, default=0):
|
||||
"""Safely format value as price"""
|
||||
try:
|
||||
return f"{self.safe_float(value, default):.2f}"
|
||||
except:
|
||||
return f"{default:.2f}"
|
||||
|
||||
def generate_pdf(self, quote_data, output_path):
|
||||
"""Generate PDF from quote data matching the exact HTML quote structure"""
|
||||
try:
|
||||
# Create document
|
||||
doc = SimpleDocTemplate(
|
||||
output_path,
|
||||
pagesize=A4,
|
||||
rightMargin=20*mm,
|
||||
leftMargin=20*mm,
|
||||
topMargin=20*mm,
|
||||
bottomMargin=20*mm
|
||||
)
|
||||
|
||||
# Build content
|
||||
story = []
|
||||
|
||||
# Extract data
|
||||
project = quote_data.get('project', {})
|
||||
geometry = quote_data.get('geometry', {})
|
||||
labor = quote_data.get('labor', {})
|
||||
materials = quote_data.get('materials', [])
|
||||
calculation = quote_data.get('calculation', {})
|
||||
|
||||
# Company header - exact match to HTML
|
||||
story.append(Paragraph("TILBUD FRA Tømrer- og Snedkermester Mikael Holck", self.styles['CompanyHeader']))
|
||||
story.append(Spacer(1, 10*mm))
|
||||
|
||||
# Customer and project info - exact match
|
||||
story.append(Paragraph(f"Til: {project.get('customer_name', 'N/A')}", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Projekt: {project.get('project_name', 'N/A')}", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Dato: {datetime.now().strftime('%d.%m.%Y')}", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Greeting - exact match
|
||||
story.append(Paragraph(f"Kære {project.get('customer_name', 'N/A')},", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 3*mm))
|
||||
|
||||
# Thank you message - exact match
|
||||
story.append(Paragraph(f"Tak for din henvendelse vedrørende {project.get('project_description', 'dit projekt')}.", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 3*mm))
|
||||
|
||||
# Company motto - exact match
|
||||
story.append(Paragraph("Measure once – cut thrice - Dette er kernen i alt, hvad vi laver hos Tømrer- og Snedkermester Mikael Holck.", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Project description header - exact match
|
||||
story.append(Paragraph("PROJEKTBESKRIVELSE:", self.styles['QuoteHeader']))
|
||||
story.append(Paragraph("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.", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
|
||||
# Work section header - exact match
|
||||
story.append(Paragraph("ARBEJDE DER UDFØRES:", self.styles['QuoteHeader']))
|
||||
|
||||
# Work details - exact match to HTML format
|
||||
if geometry.get('roof_type'):
|
||||
story.append(Paragraph(f"• Tag type: {geometry['roof_type']}", self.styles['BodySpaced']))
|
||||
if geometry.get('total_area'):
|
||||
story.append(Paragraph(f"• Samlet areal: {geometry['total_area']} m²", self.styles['BodySpaced']))
|
||||
if labor.get('total_work_hours'):
|
||||
carpenter_count = labor.get('carpenter_count', 1)
|
||||
hourly_rate = self.safe_float(labor.get('hourly_rate'), 580)
|
||||
story.append(Paragraph(f"• Arbejdstimer: {labor['total_work_hours']} timer med {carpenter_count} tømrere", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"• Timepris: {self.safe_format_price(hourly_rate)} kr/time", self.styles['BodySpaced']))
|
||||
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Materials section - exact match
|
||||
story.append(Paragraph("MATERIALER:", self.styles['QuoteHeader']))
|
||||
|
||||
# Materials list - exact match to HTML format
|
||||
material_total = 0
|
||||
for material in materials:
|
||||
material_category = material.get('material_category', 'materialer')
|
||||
quantity = material.get('quantity', 1)
|
||||
total_price = self.safe_float(material.get('total_price'), 0)
|
||||
material_total += total_price
|
||||
story.append(Paragraph(f"• {material_category}: {quantity} stk. - {self.safe_format_price(total_price)} kr", self.styles['BodySpaced']))
|
||||
|
||||
story.append(Spacer(1, 3*mm))
|
||||
story.append(Paragraph("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.", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Price specification - exact match to HTML
|
||||
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
|
||||
|
||||
# Calculate values to match HTML exactly
|
||||
try:
|
||||
labor_cost = self.safe_float(calculation.get('total_labor_cost'), 0)
|
||||
material_cost = self.safe_float(calculation.get('total_material_cost'), 285)
|
||||
subtotal = labor_cost + material_cost
|
||||
overhead_pct = 15.0
|
||||
profit_pct = 20.0
|
||||
vat_pct = 25.0
|
||||
|
||||
overhead = subtotal * (overhead_pct / 100)
|
||||
profit = subtotal * (profit_pct / 100)
|
||||
before_vat = subtotal + overhead + profit
|
||||
vat = before_vat * (vat_pct / 100)
|
||||
total = before_vat + vat
|
||||
|
||||
# Price lines - exact format match
|
||||
story.append(Paragraph(f"Arbejdsløn: {self.safe_format_price(labor_cost)} kr", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Materialer: {self.safe_format_price(material_cost)} kr", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Subtotal: {self.safe_format_price(subtotal)} kr", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Overhead ({overhead_pct:.1f}%): {self.safe_format_price(overhead)} kr", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Fortjeneste ({profit_pct:.1f}%): {self.safe_format_price(profit)} kr", self.styles['BodySpaced']))
|
||||
story.append(Paragraph(f"Moms ({vat_pct:.1f}%): {self.safe_format_price(vat)} kr", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 3*mm))
|
||||
story.append(Paragraph(f"<b>SAMLET PRIS: {self.safe_format_price(total)} kr</b>", self.styles['PriceText']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
except Exception as e:
|
||||
print(f"Error in price calculation: {e}")
|
||||
print(f"Debug - calculation data: {calculation}")
|
||||
# Fallback to simple pricing
|
||||
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
|
||||
story.append(Paragraph("Priser beregnes efter aftale", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Company promise section - exact match
|
||||
story.append(Paragraph("VORES LØFTE TIL DIG:", self.styles['QuoteHeader']))
|
||||
story.append(Paragraph("• Præcision og ordentlighed i alt hvad vi laver", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Tradition og transformation går hånd i hånd", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Meningsfuldt, ordentligt og bæredygtigt håndværk", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Vi kommer til tiden og står inde for vores arbejde", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 3*mm))
|
||||
story.append(Paragraph("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.", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Guarantees section - exact match
|
||||
story.append(Paragraph("GARANTIER OG SERVICE:", self.styles['QuoteHeader']))
|
||||
story.append(Paragraph("• Gratis tagtjek tilbydes", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Professionel rådgivning i valg af løsninger", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Korrekt dokumentation og tryghed", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("• Kvalitet der holder i mange år", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Validity - exact match
|
||||
story.append(Paragraph("<b>TILBUDDET ER GYLDIGT I 30 DAGE</b>", self.styles['PriceText']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Closing - exact match
|
||||
story.append(Paragraph("Med venlig hilsen", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("Tømrer- og Snedkermester Mikael Holck", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Contact info - exact match
|
||||
story.append(Paragraph("Kontakt:", self.styles['QuoteHeader']))
|
||||
story.append(Paragraph("Telefon: XX XX XX XX", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("Email: [email protected]", self.styles['BodySpaced']))
|
||||
story.append(Paragraph("Website: www.3byggetilbud.dk", self.styles['BodySpaced']))
|
||||
story.append(Spacer(1, 6*mm))
|
||||
|
||||
# Final paragraph - exact match
|
||||
story.append(Paragraph("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.", self.styles['BodySpaced']))
|
||||
|
||||
# Build PDF
|
||||
doc.build(story)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating PDF: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main function to handle command line arguments"""
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python3 pdf_generator.py <json_data_file> <output_pdf_path>")
|
||||
sys.exit(1)
|
||||
|
||||
json_file = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
try:
|
||||
# Read quote data from JSON file
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
quote_data = json.load(f)
|
||||
|
||||
# Generate PDF
|
||||
generator = TilbudgivernPDFGenerator()
|
||||
success = generator.generate_pdf(quote_data, output_path)
|
||||
|
||||
if success:
|
||||
print(f"SUCCESS: PDF generated at {output_path}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("ERROR: Failed to generate PDF")
|
||||
sys.exit(1)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: JSON file not found: {json_file}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: Invalid JSON in file {json_file}: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,443 @@
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class OrdrestyringService {
|
||||
constructor() {
|
||||
this.apiUrl = 'https://beta7-api.ordrestyring.dk/graphql';
|
||||
this.apiToken = process.env.ORDRESTYRING_API_TOKEN || 'SfwkRVY92qVee8tM';
|
||||
this.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiToken}`
|
||||
};
|
||||
}
|
||||
|
||||
async makeGraphQLRequest(query, variables = {}) {
|
||||
try {
|
||||
const response = await axios.post(this.apiUrl, {
|
||||
query,
|
||||
variables
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiToken}`
|
||||
}
|
||||
}); if (response.data.errors) {
|
||||
logger.error('GraphQL errors:', response.data.errors);
|
||||
throw new Error(`GraphQL Error: ${response.data.errors[0].message}`);
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
logger.error('Ordrestyring API error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test API connection
|
||||
async testConnection() {
|
||||
const query = `
|
||||
query {
|
||||
cases(pagination: {cursor: null, limit: 1}) {
|
||||
items {
|
||||
id
|
||||
caseNumber
|
||||
}
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.makeGraphQLRequest(query);
|
||||
logger.info('Ordrestyring API connection successful');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Ordrestyring API connection failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all cases with pagination
|
||||
async getAllCases(limit = 50) {
|
||||
let allCases = [];
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage) {
|
||||
const query = `
|
||||
query($cursor: String, $limit: Int) {
|
||||
cases(pagination: {cursor: $cursor, limit: $limit}, orderBy: {field: "updatedAt", direction: DESC}) {
|
||||
items {
|
||||
id
|
||||
caseNumber
|
||||
createdAt
|
||||
updatedAt
|
||||
status {
|
||||
id
|
||||
name
|
||||
}
|
||||
customer {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
address
|
||||
}
|
||||
projectName
|
||||
projectDescription
|
||||
total {
|
||||
totalAmountExVat
|
||||
totalAmountIncVat
|
||||
currency
|
||||
}
|
||||
validUntil
|
||||
caseType {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
nextCursor
|
||||
previousCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.makeGraphQLRequest(query, { cursor, limit });
|
||||
const cases = result.cases;
|
||||
|
||||
allCases = allCases.concat(cases.items);
|
||||
cursor = cases.nextCursor;
|
||||
hasNextPage = cursor !== null;
|
||||
|
||||
logger.info(`Fetched ${cases.items.length} cases, total so far: ${allCases.length}`);
|
||||
|
||||
// Rate limiting - wait between requests
|
||||
if (hasNextPage) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching cases:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return allCases;
|
||||
}
|
||||
|
||||
// Get detailed case by ID
|
||||
async getCaseById(caseId) {
|
||||
const query = `
|
||||
query($id: Int!) {
|
||||
caseById(id: $id) {
|
||||
id
|
||||
caseNumber
|
||||
createdAt
|
||||
updatedAt
|
||||
status {
|
||||
id
|
||||
name
|
||||
}
|
||||
customer {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
address
|
||||
}
|
||||
projectName
|
||||
projectDescription
|
||||
total {
|
||||
totalAmountExVat
|
||||
totalAmountIncVat
|
||||
currency
|
||||
}
|
||||
validUntil
|
||||
caseType {
|
||||
id
|
||||
name
|
||||
}
|
||||
caseMaterials {
|
||||
items {
|
||||
id
|
||||
description
|
||||
quantity
|
||||
unitPrice
|
||||
totalPrice
|
||||
unit {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
caseDocuments {
|
||||
items {
|
||||
id
|
||||
filename
|
||||
fileSize
|
||||
mimeType
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.makeGraphQLRequest(query, { id: parseInt(caseId) });
|
||||
return result.caseById;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching case ${caseId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform Ordrestyring case to our quote format
|
||||
transformCaseToQuote(ordrestyringCase) {
|
||||
return {
|
||||
external_id: ordrestyringCase.id,
|
||||
external_case_number: ordrestyringCase.caseNumber,
|
||||
customer_name: ordrestyringCase.customer?.name || null,
|
||||
customer_email: ordrestyringCase.customer?.email || null,
|
||||
customer_phone: ordrestyringCase.customer?.phone || null,
|
||||
customer_address: ordrestyringCase.customer?.address || null,
|
||||
project_name: ordrestyringCase.projectName,
|
||||
project_description: ordrestyringCase.projectDescription,
|
||||
total_amount_excl_vat: parseFloat(ordrestyringCase.total?.totalAmountExVat || 0),
|
||||
total_amount_incl_vat: parseFloat(ordrestyringCase.total?.totalAmountIncVat || 0),
|
||||
currency: ordrestyringCase.total?.currency || 'DKK',
|
||||
status: ordrestyringCase.status?.name || ordrestyringCase.status,
|
||||
case_type: ordrestyringCase.caseType?.name || null,
|
||||
valid_until: ordrestyringCase.validUntil,
|
||||
created_at: ordrestyringCase.createdAt,
|
||||
updated_at: ordrestyringCase.updatedAt,
|
||||
source: 'ordrestyring'
|
||||
};
|
||||
}
|
||||
|
||||
// Import cases into our database
|
||||
async importCasesToDatabase(databaseService) {
|
||||
try {
|
||||
logger.info('Starting import from Ordrestyring API...');
|
||||
|
||||
// Test connection first
|
||||
await this.testConnection();
|
||||
|
||||
// Get all cases
|
||||
const cases = await this.getAllCases();
|
||||
logger.info(`Retrieved ${cases.length} cases from Ordrestyring`);
|
||||
|
||||
// Check if we need to create import table
|
||||
await this.ensureImportTableExists(databaseService);
|
||||
|
||||
let importedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const ordrestyringCase of cases) {
|
||||
try {
|
||||
// Transform to our format
|
||||
const quoteData = this.transformCaseToQuote(ordrestyringCase);
|
||||
|
||||
// Check if already exists
|
||||
const [existing] = await databaseService.query(
|
||||
'SELECT id FROM imported_quotes WHERE external_id = ? AND source = ?',
|
||||
[quoteData.external_id, quoteData.source]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
await databaseService.query(`
|
||||
UPDATE imported_quotes SET
|
||||
external_case_number = ?,
|
||||
customer_name = ?,
|
||||
customer_email = ?,
|
||||
customer_phone = ?,
|
||||
customer_address = ?,
|
||||
project_name = ?,
|
||||
project_description = ?,
|
||||
total_amount_excl_vat = ?,
|
||||
total_amount_incl_vat = ?,
|
||||
currency = ?,
|
||||
status = ?,
|
||||
valid_until = ?,
|
||||
updated_at = ?,
|
||||
imported_at = NOW()
|
||||
WHERE external_id = ? AND source = ?
|
||||
`, [
|
||||
quoteData.external_case_number,
|
||||
quoteData.customer_name,
|
||||
quoteData.customer_email,
|
||||
quoteData.customer_phone,
|
||||
quoteData.customer_address,
|
||||
quoteData.project_name,
|
||||
quoteData.project_description,
|
||||
quoteData.total_amount_excl_vat,
|
||||
quoteData.total_amount_incl_vat,
|
||||
quoteData.currency,
|
||||
quoteData.status,
|
||||
quoteData.valid_until,
|
||||
quoteData.updated_at,
|
||||
quoteData.external_id,
|
||||
quoteData.source
|
||||
]);
|
||||
updatedCount++;
|
||||
} else {
|
||||
// Insert new
|
||||
await databaseService.query(`
|
||||
INSERT INTO imported_quotes (
|
||||
external_id, external_case_number, customer_name, customer_email,
|
||||
customer_phone, customer_address, project_name, project_description,
|
||||
total_amount_excl_vat, total_amount_incl_vat, currency, status,
|
||||
case_type, valid_until, created_at, updated_at, source, imported_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [
|
||||
quoteData.external_id,
|
||||
quoteData.external_case_number,
|
||||
quoteData.customer_name,
|
||||
quoteData.customer_email,
|
||||
quoteData.customer_phone,
|
||||
quoteData.customer_address,
|
||||
quoteData.project_name,
|
||||
quoteData.project_description,
|
||||
quoteData.total_amount_excl_vat,
|
||||
quoteData.total_amount_incl_vat,
|
||||
quoteData.currency,
|
||||
quoteData.status,
|
||||
quoteData.valid_until,
|
||||
quoteData.created_at,
|
||||
quoteData.updated_at,
|
||||
quoteData.source
|
||||
]);
|
||||
importedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing case ${ordrestyringCase.id}:`, error);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total_cases: cases.length,
|
||||
imported: importedCount,
|
||||
updated: updatedCount,
|
||||
skipped: skippedCount
|
||||
};
|
||||
|
||||
logger.info('Import completed:', summary);
|
||||
return summary;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Import failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure import table exists
|
||||
async ensureImportTableExists(databaseService) {
|
||||
const createTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS imported_quotes (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
external_id INT NOT NULL,
|
||||
external_case_number VARCHAR(100),
|
||||
customer_name VARCHAR(255),
|
||||
customer_email VARCHAR(255),
|
||||
customer_phone VARCHAR(50),
|
||||
customer_address TEXT,
|
||||
project_name VARCHAR(255),
|
||||
project_description TEXT,
|
||||
total_amount_excl_vat DECIMAL(12,2),
|
||||
total_amount_incl_vat DECIMAL(12,2),
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
status VARCHAR(50),
|
||||
valid_until DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
source VARCHAR(50) NOT NULL,
|
||||
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_external_id_source (external_id, source),
|
||||
INDEX idx_customer_name (customer_name),
|
||||
INDEX idx_total_amount (total_amount_incl_vat),
|
||||
INDEX idx_created_at (created_at),
|
||||
UNIQUE KEY unique_external_source (external_id, source)
|
||||
)
|
||||
`;
|
||||
|
||||
await databaseService.query(createTableQuery);
|
||||
logger.info('Import table ensured to exist');
|
||||
}
|
||||
|
||||
// Generate statistics from imported data
|
||||
async generateStatistics(databaseService) {
|
||||
try {
|
||||
// Basic statistics
|
||||
const [totalStats] = await databaseService.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_quotes,
|
||||
SUM(total_amount_incl_vat) as total_value,
|
||||
AVG(total_amount_incl_vat) as average_value,
|
||||
MIN(total_amount_incl_vat) as min_value,
|
||||
MAX(total_amount_incl_vat) as max_value,
|
||||
COUNT(DISTINCT customer_name) as unique_customers
|
||||
FROM imported_quotes
|
||||
WHERE source = 'ordrestyring'
|
||||
`);
|
||||
|
||||
// Monthly statistics
|
||||
const monthlyStats = await databaseService.query(`
|
||||
SELECT
|
||||
DATE_FORMAT(created_at, '%Y-%m') as month,
|
||||
COUNT(*) as quote_count,
|
||||
SUM(total_amount_incl_vat) as monthly_value,
|
||||
AVG(total_amount_incl_vat) as avg_quote_value
|
||||
FROM imported_quotes
|
||||
WHERE source = 'ordrestyring'
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
|
||||
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
|
||||
ORDER BY month DESC
|
||||
`);
|
||||
|
||||
// Status distribution
|
||||
const statusStats = await databaseService.query(`
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count,
|
||||
SUM(total_amount_incl_vat) as total_value
|
||||
FROM imported_quotes
|
||||
WHERE source = 'ordrestyring'
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
|
||||
// Top customers
|
||||
const topCustomers = await databaseService.query(`
|
||||
SELECT
|
||||
customer_name,
|
||||
COUNT(*) as quote_count,
|
||||
SUM(total_amount_incl_vat) as total_value,
|
||||
AVG(total_amount_incl_vat) as avg_value
|
||||
FROM imported_quotes
|
||||
WHERE source = 'ordrestyring'
|
||||
AND customer_name IS NOT NULL
|
||||
GROUP BY customer_name
|
||||
HAVING quote_count > 1
|
||||
ORDER BY total_value DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
return {
|
||||
total: totalStats,
|
||||
monthly: monthlyStats,
|
||||
status: statusStats,
|
||||
top_customers: topCustomers
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error generating statistics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OrdrestyringService;
|
||||
@@ -186,6 +186,99 @@ class RoofGeometryService {
|
||||
}
|
||||
}
|
||||
|
||||
// AI-baseret timeberegning baseret på historiske data
|
||||
async estimateWorkHoursWithAI(projectData, historicalData) {
|
||||
try {
|
||||
// Hent historiske projekter med lignende karakteristika
|
||||
const similarProjects = await this.db.pool.execute(`
|
||||
SELECT rg.total_area, rg.roof_type, rg.complexity_factor, pl.total_work_hours, pl.carpenter_count
|
||||
FROM roof_geometry rg
|
||||
JOIN project_labor pl ON rg.project_id = pl.project_id
|
||||
WHERE rg.roof_type = ? AND rg.total_area BETWEEN ? AND ?
|
||||
ORDER BY ABS(rg.total_area - ?) ASC
|
||||
LIMIT 10
|
||||
`, [
|
||||
projectData.roof_type,
|
||||
projectData.total_area * 0.7, // -30%
|
||||
projectData.total_area * 1.3, // +30%
|
||||
projectData.total_area
|
||||
]);
|
||||
|
||||
if (similarProjects[0].length === 0) {
|
||||
// Fallback til standard estimering hvis ingen historiske data
|
||||
return this.estimateWorkHours(projectData.total_area, projectData.complexity_factor, projectData.roof_type);
|
||||
}
|
||||
|
||||
// Beregn gennemsnit fra historiske data
|
||||
const avgHoursPerM2 = similarProjects[0].reduce((sum, project) => {
|
||||
return sum + (project.total_work_hours / project.total_area);
|
||||
}, 0) / similarProjects[0].length;
|
||||
|
||||
const estimatedHours = Math.max(
|
||||
projectData.total_area * avgHoursPerM2 * projectData.complexity_factor,
|
||||
8 // Minimum 8 timer
|
||||
);
|
||||
|
||||
logger.info('AI work hours estimation completed', {
|
||||
projectData,
|
||||
similarProjectsCount: similarProjects[0].length,
|
||||
avgHoursPerM2,
|
||||
estimatedHours
|
||||
});
|
||||
|
||||
return Math.round(estimatedHours);
|
||||
} catch (error) {
|
||||
logger.error('Error in AI work hours estimation, falling back to standard:', error);
|
||||
return this.estimateWorkHours(projectData.total_area, projectData.complexity_factor, projectData.roof_type);
|
||||
}
|
||||
}
|
||||
|
||||
// Statistik baseret timeforslag
|
||||
async getWorkHourStatistics(roofType, areaRange) {
|
||||
try {
|
||||
const [areaMin, areaMax] = areaRange || [0, 1000];
|
||||
|
||||
const stats = await this.db.pool.execute(`
|
||||
SELECT
|
||||
COUNT(*) as project_count,
|
||||
AVG(pl.total_work_hours) as avg_hours,
|
||||
MIN(pl.total_work_hours) as min_hours,
|
||||
MAX(pl.total_work_hours) as max_hours,
|
||||
AVG(pl.total_work_hours / rg.total_area) as avg_hours_per_m2,
|
||||
AVG(pl.carpenter_count) as avg_carpenters
|
||||
FROM roof_geometry rg
|
||||
JOIN project_labor pl ON rg.project_id = pl.project_id
|
||||
WHERE rg.roof_type = ? AND rg.total_area BETWEEN ? AND ?
|
||||
`, [roofType, areaMin, areaMax]);
|
||||
|
||||
if (stats[0].length === 0 || stats[0][0].project_count === 0) {
|
||||
return {
|
||||
hasData: false,
|
||||
message: 'Ingen historiske data for denne tagtype og størrelse'
|
||||
};
|
||||
}
|
||||
|
||||
const data = stats[0][0];
|
||||
return {
|
||||
hasData: true,
|
||||
projectCount: data.project_count,
|
||||
averageHours: Math.round(data.avg_hours),
|
||||
hoursRange: {
|
||||
min: Math.round(data.min_hours),
|
||||
max: Math.round(data.max_hours)
|
||||
},
|
||||
averageHoursPerM2: parseFloat(data.avg_hours_per_m2).toFixed(2),
|
||||
averageCarpenters: Math.round(data.avg_carpenters)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting work hour statistics:', error);
|
||||
return {
|
||||
hasData: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Genberegn estimater hvis geometri ændres
|
||||
async recalculateEstimates(projectId) {
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.29a6efb4.css",
|
||||
"main.js": "/static/js/main.52b9f72f.js",
|
||||
"main.css": "/static/css/main.27af5b33.css",
|
||||
"main.js": "/static/js/main.b4962e87.js",
|
||||
"index.html": "/index.html",
|
||||
"main.29a6efb4.css.map": "/static/css/main.29a6efb4.css.map",
|
||||
"main.52b9f72f.js.map": "/static/js/main.52b9f72f.js.map"
|
||||
"main.27af5b33.css.map": "/static/css/main.27af5b33.css.map",
|
||||
"main.b4962e87.js.map": "/static/js/main.b4962e87.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.29a6efb4.css",
|
||||
"static/js/main.52b9f72f.js"
|
||||
"static/css/main.27af5b33.css",
|
||||
"static/js/main.b4962e87.js"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="da"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="AI-baseret tilbudsberegner specifikt for tømrerfaget og træarbejde"/><title>Tilbudgivern - AI Tilbudsberegner for Tømrere</title><script defer="defer" src="/static/js/main.52b9f72f.js"></script><link href="/static/css/main.29a6efb4.css" rel="stylesheet"></head><body><noscript>Du skal aktivere JavaScript for at køre denne app.</noscript><div id="root"></div></body></html>
|
||||
<!doctype html><html lang="da"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="AI-baseret tilbudsberegner specifikt for tømrerfaget og træarbejde"/><title>Tilbudgivern - AI Tilbudsberegner for Tømrere</title><script defer="defer" src="/static/js/main.b4962e87.js"></script><link href="/static/css/main.27af5b33.css" rel="stylesheet"></head><body><noscript>Du skal aktivere JavaScript for at køre denne app.</noscript><div id="root"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
||||
-- Opret imported_quotes tabel til Ordrestyring import
|
||||
CREATE TABLE IF NOT EXISTS imported_quotes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
external_id VARCHAR(100) NOT NULL,
|
||||
external_case_number VARCHAR(100) NOT NULL,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
customer_email VARCHAR(255),
|
||||
customer_phone VARCHAR(50),
|
||||
customer_address TEXT,
|
||||
project_name VARCHAR(255) NOT NULL,
|
||||
project_description TEXT,
|
||||
total_amount_excl_vat DECIMAL(15,2) NOT NULL,
|
||||
total_amount_incl_vat DECIMAL(15,2) NOT NULL,
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
status ENUM('Sendt tilbud', 'Under behandling', 'Accepteret', 'Afvist', 'Annulleret') NOT NULL,
|
||||
case_type VARCHAR(100),
|
||||
valid_until DATE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
source VARCHAR(50) DEFAULT 'ordrestyring',
|
||||
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Indexes for performance
|
||||
INDEX idx_external_id (external_id),
|
||||
INDEX idx_case_number (external_case_number),
|
||||
INDEX idx_customer_name (customer_name),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_case_type (case_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_total_amount (total_amount_incl_vat),
|
||||
INDEX idx_imported_at (imported_at),
|
||||
|
||||
UNIQUE KEY unique_external_case (external_id, external_case_number)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
quote_text
|
||||
TILBUD FRA Tømrer- og Snedkermester Mikael Holck\n\nTil: Hans Hansen\nProjekt: Kunda a/s\nDato: 17.9.2025\n\nKære Hans Hansen,\n\nTak for din henvendelse vedrørende udskiftning af b7 tag til nyt b7 tag.\n\nMeasure once – cut thrice - Dette er kernen i alt, hvad vi laver hos Tømrer- og Snedkermester Mikael Holck.\n\nPROJEKTBESKRIVELSE:\nTagrenovering 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.\n\nARBEJDE DER UDFØRES:\n• Tag type: skraat_tag\n• Samlet areal: 342.24 m²\n• Arbejdstimer: 2.00 timer med 2 tømrere\n• Timepris: 580.00 kr/time\n\nMATERIALER:\n• tagmaterialer: 1 stk. - 285.00 kr\n\nVi 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.\n\nPRISSPECIFIKATION:\nArbejdsløn: 0.00 kr\nMaterialer: 285.00 kr\nSubtotal: 285.00 kr\nOverhead (15.00%): 42.75 kr\nFortjeneste (20.00%): 57.00 kr\nMoms (25.00%): 71.25 kr\n\nSAMLET PRIS: 356.25 kr\n\nVORES LØFTE TIL DIG:\n• Præcision og ordentlighed i alt hvad vi laver\n• Tradition og transformation går hånd i hånd\n• Meningsfuldt, ordentligt og bæredygtigt håndværk\n• Vi kommer til tiden og står inde for vores arbejde\n\nVi 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.\n\nGARANTIER OG SERVICE:\n• Gratis tagtjek tilbydes\n• Professionel rådgivning i valg af løsninger\n• Korrekt dokumentation og tryghed\n• Kvalitet der holder i mange år\n\nTILBUDDET ER GYLDIGT I 30 DAGE\n\nMed venlig hilsen\nTømrer- og Snedkermester Mikael Holck\n\nKontakt:\nTelefon: XX XX XX XX\nEmail: [email protected]\nWebsite: www.3byggetilbud.dk\n\nMed 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20250917132703+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917132703+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1357
|
||||
>>
|
||||
stream
|
||||
GatU3gMZ"A&:Ml+eB:J.ME(%tSN!hk%?k<Y$_DW1=uGQaFF7TQ%3tk$X%`Z%=r[qq+q*W"3d!H*M@d#^7@NC4!H=[Hh3\29O=YsmQ4(eZ`6$7g2rr63Vj8FPLd9l%(5_qpn):N3ZolsHUC^CNK7:nX[1>i[DCH.S\>@\7Fbgc#1^rn:iQt4EO8%O$0V/atR6,t*.%tOP8mta-SdADQ=pkL011MM`cW0t-I=gMpP2G6l^TK9E^[Q@q@S3cLZX"77B`hfd%/).'s%A+7PJJ0j9oYTPHATiNn<oPao+:#1D!f'so1H*fWd5DJ*l,[jj:'[Hh#T*HY='<a>n);eQ5s,XVHsKYO'&)K^"+t9:382"/=;4'j8)CG>j6]mJ>d*@:?].l7%AnGKmdSO/1Um/QEV"`<g6kM[=GC;b0X7Y04;]`9J1H:b7rSA('/*7JsJE]48nm#3hM0DKiX!5B[+FN?%+SCbCd;P'6t4lhI'oqnjU?a;Y76ATN=bFk?Ac0XTdV_i[C+e":6X2I9:>(LTQ6"D=MFBeK'D=4jRFj`*LeLI%l)BSi/_M[&*.21rsO>%el_TIJsjD,kpiq@643*5L/9)q0:]13=A@U(:p<_L^K&OW*nnEIg_ro>c9h?%=A7NjLD+\&D.G&%kO)nUDp3MT"oOVWAO;1153-9QD1Nn<MD#oF-Yco__cY;oTnSJnMk\ea]-G_(fq*-Rs@VcFWY@>)043@g3eO]CKB&C@u.^0>ROYNC8#ERZrXf?O!,Wq2a#(S.[*\hM'+ecLA)5J9gTj=NSD>X8Jning8.;lb8NO6hT&tm0oY*@HlJ(.NaklF=bgTNOae/;Zo#uu&3s^eI('jnW&)D[rYs&;!Ak(3"a%bA*#jLB@CVRBQWhmm@SV@*!STbFiX_];C*?o/XX2D>!7\;kB\ju)3Sr6Rl6&,UJoUf_jUl[<A3BoGZqVO.\hg%%!%,4I#\din%1hWgOmm(P`7)it0qNP2*qd3f-m)u\1b0DXY%5(W"Mk/l;!Z=l/Z5;OY=F?j:jO-Dpn/BZ3#Go/EGi$1SnA]IOrr%<9p6mD%BB/KK:2j7`F,)Q)9`9,*@pr7UT/O?3f9aG[Y7!'$'`W'R2o=a4;S/<$M5K/"dJj3;Q-QF_%2]>2_ba2!Z\7Q[U!(qqC4M:`J[G):ot["9T=(eg6qWJfVL$73"1lte@uJc7r?kP;++XS8qNhSV(6C<Rsg:rR#EC'_dr?9B*YRO']*nEVoeWjn'0E3#@Ma%(3/Q'Leg7_q>quWEk\3E!G@mUY-_'S$fGu>(pkg5pDHQ2')8eJhBN=#g\fM#pRW+EST&Rg>_tPFP^X@Dq5&hm\7kW`peTbGPi:b392'&K~>endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1184
|
||||
>>
|
||||
stream
|
||||
GatU2gN)%,&:O:Slq:u]Nlr'JML):$1TDdBj%-8@cKGEe5RJi/\0*&Xs1Sk5TpgpR;=Wj:$<Z[:mc>E"E:HBNmtV8.I0RH8+@&'1EZfoH5W46O]D2,Pln'I.M%VHb&4`umOsn0HiO7KYlf%t/ONo;dWHJd-etHa4?/DT]J`W^E3poeeG@O/fF9JFh#MC>45MLFTnT?Bp\@](%NuJ4>B@$m@lp,h]aT"LWY&@re#']\'@LU)oFcp,L,a(.7.-sA:NOo'P5X>W'BE=)r5kgo5klPHihfh:q.:gmFge9*Udtj_H4;IC2?'ZariD)')+F4TY:c50QP\.Qd;A<ol5QI&Rem$:).mVN7+-<0ELu3_1e!(0F#@fTmRp>38[OVMJ%5k6SV7,U]G69OE-`ceFVH[Ab\nLYoAEN0=+_)[uD#,u$086Y]HUQV3[2]1*O>d0O(Jq-3iYmc;pd&:';e(!-&iJ^"l%?+d]E,$6r/pNuTY^h%I>$Y/fApB,G6Y`j(ECDRO<qq26Hn(dJcXm9BQ!;Es7RlkCqt'aV(g]_M?'EC*4To/L$P7DX9g_X['"@4Q`;XY>"Q/s)W]>-h8U5ulK]*j1,QD^"^Z')6Hfre&6!+_0m@bM!e8T;YRt=Ba=Cmgb!O]'Z1%@`bW?Z37gtUIq=UB51nDO^#qXO#rUfi+!%e>>AkrPWV6qWgB<D8&$hMG4>FF"Lj3<+an`VQEn$L5_A(cZ2cu"aLZ*e/]%_us1Cgdg]*C<:_F?9)u+Sr%Dk+r*Ub"[SR:Clga@Td^OJA?LFJGg%0D-u&SGS*bLI!TH2I/j=J/J<b'DgM\+g_lV0UaT42s15a,/]A3jr%g^fb$YRj$TH\$oJACSKRFcYrJKm\6"b0!_#kQcbA3ng=:<gOH2sBfqS?R-mE#^WkSMtmMjgu8l\[V*X*#pbMXC=sOq.XO_=s'tEI@X>iZ8NRj-#H1qmSBakei=BP;.@r_'RE-4bbUs](^`"&c'7fBgm^qUY+\n2h1Ku'\nl%L,j'8Y.`b8.Z]A`S$*a#atZ"C5<J&K[;lh?\@9JrdeDVV9Sp/oT-IJrn7:dW'@A=TqX1SsB<Y-nnW8M_K*&ZI897kJ2Ghg?cP&qASMS<X3OTkh!Z_sp21h;m)(l;'j//'CS2Z2^8PO=o]uq'BCXJ@p]e-S]Dts6WZ%\,9!;RiW=o~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 11
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000536 00000 n
|
||||
0000000740 00000 n
|
||||
0000000808 00000 n
|
||||
0000001091 00000 n
|
||||
0000001156 00000 n
|
||||
0000002604 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<0dbd524bebd54924bde658b18d043468><0dbd524bebd54924bde658b18d043468>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 11
|
||||
>>
|
||||
startxref
|
||||
3880
|
||||
%%EOF
|
||||
Binary file not shown.
@@ -653,6 +653,23 @@
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.generate-pdf-btn {
|
||||
background: #9b59b6;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.generate-pdf-btn:hover {
|
||||
background: #8e44ad;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.generate-pdf-btn:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
|
||||
@@ -150,6 +150,34 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to generate PDF for quote
|
||||
const generatePDF = async (quote) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${apiBaseUrl}/api/quotes/${quote.id}/generate-pdf`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('PDF genereret succesfuldt! Du kan nu downloade det.');
|
||||
// Refresh quotes to show updated status
|
||||
loadCompletedQuotes();
|
||||
} else {
|
||||
throw new Error(data.error || 'Fejl ved generering af PDF');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Fejl ved generering af PDF: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQuote = (quote) => {
|
||||
const blob = new Blob([quote.quote_text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -257,6 +285,12 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
|
||||
<span className="stat-label">AI genereret:</span>
|
||||
<span className="stat-value">{quotes.filter(q => q.ai_model !== 'static').length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Samlet arbejdstimer:</span>
|
||||
<span className="stat-value">
|
||||
{quotes.reduce((sum, q) => sum + (parseFloat(q.total_work_hours) || 0), 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -385,7 +419,7 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
|
||||
👁️ Vis
|
||||
</button>
|
||||
|
||||
{quote.quote_format === 'pdf' && (
|
||||
{quote.quote_format === 'pdf' ? (
|
||||
<button
|
||||
onClick={() => downloadPDF(quote)}
|
||||
className="action-btn pdf-btn"
|
||||
@@ -393,6 +427,15 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
|
||||
>
|
||||
📄 PDF
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => generatePDF(quote)}
|
||||
className="action-btn generate-pdf-btn"
|
||||
title="Generér PDF med logo og firmaoplysninger"
|
||||
disabled={loading}
|
||||
>
|
||||
📄 Generér PDF
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -619,13 +662,21 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
{selectedQuote.quote_format === 'pdf' && (
|
||||
{selectedQuote.quote_format === 'pdf' ? (
|
||||
<button
|
||||
onClick={() => downloadPDF(selectedQuote)}
|
||||
className="action-btn pdf-btn"
|
||||
>
|
||||
📄 Download PDF
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => generatePDF(selectedQuote)}
|
||||
className="action-btn generate-pdf-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
📄 Generér PDF
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => downloadQuote(selectedQuote)}
|
||||
|
||||
@@ -169,6 +169,75 @@ const LaborInput = ({ apiBaseUrl, project, geometry, existingLabor, onComplete }
|
||||
return Math.ceil(totalHours / (carpenters * hoursPerDay));
|
||||
};
|
||||
|
||||
// AI-baseret timeestimering
|
||||
const getAIWorkHourEstimate = async () => {
|
||||
if (!geometry?.total_area || !geometry?.roof_type) {
|
||||
setError('Geometri data mangler for AI estimering');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Først hent statistik for lignende projekter
|
||||
const statsResponse = await fetch(
|
||||
`${apiBaseUrl}/api/work-hour-statistics/${geometry.roof_type}?areaMin=${geometry.total_area * 0.7}&areaMax=${geometry.total_area * 1.3}`
|
||||
);
|
||||
const statsData = await statsResponse.json();
|
||||
|
||||
if (statsData.success && statsData.data.hasData) {
|
||||
// Brug statistik data til at foreslå timer
|
||||
const suggestedHours = Math.round(
|
||||
parseFloat(statsData.data.averageHoursPerM2) * parseFloat(geometry.total_area)
|
||||
);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
totalWorkHours: Math.max(suggestedHours, 8), // Minimum 8 timer
|
||||
carpenterCount: statsData.data.averageCarpenters || 2
|
||||
}));
|
||||
|
||||
alert(`🤖 AI Forslag baseret på ${statsData.data.projectCount} lignende projekter:\n` +
|
||||
`• Anbefalede timer: ${Math.max(suggestedHours, 8)}\n` +
|
||||
`• Gennemsnit timer/m²: ${statsData.data.averageHoursPerM2}\n` +
|
||||
`• Anbefalede tømrere: ${statsData.data.averageCarpenters || 2}`);
|
||||
} else {
|
||||
// Fallback til API baseret estimering hvis ingen historisk data
|
||||
const estimateResponse = await fetch(`${apiBaseUrl}/api/estimate-work-hours`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roof_type: geometry.roof_type,
|
||||
total_area: parseFloat(geometry.total_area),
|
||||
complexity_factor: parseFloat(geometry.complexity_factor || 1.0)
|
||||
})
|
||||
});
|
||||
|
||||
const estimateData = await estimateResponse.json();
|
||||
if (estimateData.success) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
totalWorkHours: estimateData.data.estimatedHours
|
||||
}));
|
||||
|
||||
alert(`🤖 AI Estimering (standard beregning):\n` +
|
||||
`• Anbefalede timer: ${estimateData.data.estimatedHours}\n` +
|
||||
`• Baseret på: ${geometry.total_area} m² ${geometry.roof_type}`);
|
||||
} else {
|
||||
throw new Error('AI estimering fejlede');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI work hour estimate:', error);
|
||||
setError('Fejl ved AI timeestimering: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -310,14 +379,25 @@ const LaborInput = ({ apiBaseUrl, project, geometry, existingLabor, onComplete }
|
||||
|
||||
<div className="overview-item">
|
||||
<label>Total arbejdstimer (estimeret)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="totalWorkHours"
|
||||
value={formData.totalWorkHours}
|
||||
onChange={handleInputChange}
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<div className="input-with-ai">
|
||||
<input
|
||||
type="number"
|
||||
name="totalWorkHours"
|
||||
value={formData.totalWorkHours}
|
||||
onChange={handleInputChange}
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-estimate-btn"
|
||||
onClick={getAIWorkHourEstimate}
|
||||
disabled={!geometry?.total_area || !geometry?.roof_type}
|
||||
title="Få AI forslag til timer baseret på historiske data"
|
||||
>
|
||||
🤖 AI Forslag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-item">
|
||||
|
||||
@@ -449,7 +449,38 @@
|
||||
.overview-item small {
|
||||
margin-top: 5px;
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.input-with-ai {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-with-ai input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-estimate-btn {
|
||||
background: #8e44ad;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-estimate-btn:hover {
|
||||
background: #7d3c98;
|
||||
}
|
||||
|
||||
.ai-estimate-btn:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calculations {
|
||||
|
||||
Generated
+1
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nodemon unified-server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20250917132622+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917132622+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1339
|
||||
>>
|
||||
stream
|
||||
Gatm;D0+\p&H88.YohF)E@ff6$f=KpjuAO\'n#b4R?!)W;>`mp6&0sIPmJe.iksh<0HNK[I@f8CZmuF7p`duSV#YariVnr>Tb+DX\<[Nm'1DB8?T7&jd\E*EdZB"*I#EYaM#2/E/6^8/Fr5a%DpT#/?$d,!k`u;?N*Ao6;hnJ(8:pooirj8[aj\/:0g*`N&2,$\/sK1V:o,j!7?RsWLft/icSFN)LJLqLP5PIP/&]3>h7m5=gHY1MN\RA2^hhUV5&W^Qf&.+&rL[K]+W/Fch;kL@ju/P$&3fWiDB>N7EpG')cOXK5<Rn.koFRnS1?$%;lQ0`Rqa%SDf@:FgDqUgQD9@040k(;=AY!Q/S?Ol\Nm)V(\=O?%1HjqfLbd.n!jA()EE[uFlcq:g<=0@A_HN0c;'mKj"meXON/X%t!ObRjad"JiR0$a3dKe#qcn^AsYM[&tQfDI^?j2pJc8_2jns_pH*5juZaKsgt(,K0[0*2oRU5uD\]iGc\hn`E./H?;(*lV$A+G&^%!:(Y$?md,\4'YukFcXFeN:XJ<`-oK\HD4`m*B$%uFG9+j4ihK7%Lu!Cp>p?PN?=njF>iq94jBb\ANm9c>_JkYLKm_V7"r`<;D@p`pJU4',1HV&L1CZBe2UB'#cn61#F=.md#D"`:>ia;<181^151^f<hca.<FT03Ao!!VNRD@,koJ(hi?_Sd,uZBo@9bBQDU)0qX9VRkc&eFke`I[Th=/!F*$`sEj''6E>74`3U:&]f"/LJDh'OTAX%Yc=#(kh_3::_DRt,f!)Q/?*O<Em@ZqF1!f+iTgH\akH,8`p,pbmS+30$.[junUhK\f/=o;uFeHllg=cM&84KK<&%lT[,k4[Au(B0bXmaKZkd)idk=EN)B4-l1=-4eTL3.K0A?81her;JV-[*C<#bq1N3PE0ic^#18&.ZMp9),O@nAH3c(+/_c"Rh&@h,J-c]5696tpK6I2S#sOF0BJp_k7C+&@UE7%#nq6-6NpTm<gsj,J2UjdoY_fteLm,&77Lo^+s$&4DfTC[t>_,[(k.)j'MZ9%Df<otc'>-0SI^]kZ86UZ3L@$"E@*Br+ikOFfgUSjK]KGCFX2T5h/&bq^RgG/am.@qa%[RKECt#:B@=%cJi/n[4.<9=GG9QN^'O^PtJ..p^e>N;)j('a)[N!s1LCnEKQSUG]dg@IHR@md>fW]m@du8n.$k7%Fk%:sTV^W+",fg_/rZ4e*G_&<$OETg;3>l'`bpW,YfBB]cj((3.-XV>kER5!jagm(J?>"2YXr3Vg&8q`rlRa_/.L,MIl@+10CqTLq]cm@4UQG*7;HMS2OtcO]GsJn8#JaM[Rf~>endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1246
|
||||
>>
|
||||
stream
|
||||
GatU3>uTcA'Rf.Ggm?<j*U6i-N:U#WKJ^$/d);d8RCjq=G(aFbQ'F'$qd);!l0cL.J]WZfqkA.Zn'sE7!\V2XoDMJeHqrYPCBDG,1]n@M!MiX2V.\]2a=]7=S1*n?!c\o9,`*0341sr=[rm.S/!+pO>c-:/kV`fpG.I5!%G*+*)(a')kdWi/4q/)S_3IA($N1:Z7nA-<Y^bWt&9O#H'DbmHo>iFg)ij-Rqhgsj+eJErpW5MEDcBkDf[koPr?r#DaQj((U?9\:*Y*.A2P>sEg%+]aOT4j>?f"IH.GW/pF3I'0n@aB?qQ[nF-Q7Z/UnY*,ho.$X3<]ki6,VsdFuGf,FQ*,bFZP+r152dNGh@Y2QQ/@T>b)%TA%9tq5DU@K/Q;m'#Gi<AN"l)F1CugJJ^js/G9]@Nm,K.7#l+Im<)Y-fNf;AmW\^JEg0Da^C8bQP&!^_84$1_:9TE08%8Jjp&li7NCWXf>5"!=f>h\gac<bO$YO!*CR]Ajf@WD/D@eTp2<L-mB&J@<!b]9`.2Gp2X*&)fF;4?*%oo_NphLed\)h*<!VQ7g+5AgQaG@#6M2c>q]qZo2lD[?W,)k`V8+8l#6.'Wf2%@+eLK3&0mp#No:o-djV<+AXq(56$$mq5NU"^*:rQ;iKApp"&X_Hjf<nN"K`HTB-A/R4,-_$"5)bSG8(2^\#1blA[p<fan8HDCt_K>bLURBg5)%ld9#`g=$T5L[_f#.J*0O(S-Mha]h?+c+pKJ8_gG=Q?!R_9a2#UTg"d^+`mf^^[r;i8/15n_0uTQ_9fdj^M`CL`M$%OP@Y=CYM@@!q1C!hdH(FC!@"f9GQk+G<Xi`du.5J*X(9^6NhS%!,VPdp;V`8P2aqXZdFginBnX7'F(,ql,TS/36b+DW':60FbP!r*BO1DMPcZU"FM#g0#s_.e8PsB."g@*r';9@iqS'3ZJcNj26;J4H.@P,kIqT\[67Ze)#[[email protected]*_269>c`;.(8^h8j;n:"><&``UU/g,@7_f3)*&[..#j.oT0S:n;h%#,_A[\WG2]G^UESPGW6t_%rYEF+bs`SRJP08^>q;`@^TeXl]Z,YGUa6Arkk'[m`H\85<h4Wit?,4!JrmR9WWX1!`$+)g\)MB<=M`)*a05jJ>XKD]q'b_Nf6=Hap]3T>,'5H!*_fV/"@uOjP.Wn4*,5h3!/If*'JnCeeubSr-(ML.2t@P%E1n5[77jS'/(h4(MLLAZL[JVr(")IZi:#a\ktH~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 11
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000536 00000 n
|
||||
0000000740 00000 n
|
||||
0000000808 00000 n
|
||||
0000001091 00000 n
|
||||
0000001156 00000 n
|
||||
0000002586 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<57b70d3f10df9c66391a696d364a248d><57b70d3f10df9c66391a696d364a248d>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 11
|
||||
>>
|
||||
startxref
|
||||
3924
|
||||
%%EOF
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const OrdrestyringService = require('./backend/src/services/ordrestyringService');
|
||||
|
||||
async function testBearerAuth() {
|
||||
console.log('🧪 Testing Ordrestyring API with Bearer token authentication...\n');
|
||||
|
||||
const service = new OrdrestyringService();
|
||||
|
||||
try {
|
||||
console.log('📡 Testing connection...');
|
||||
const testResult = await service.testConnection();
|
||||
|
||||
if (testResult.success) {
|
||||
console.log('✅ Connection successful!');
|
||||
console.log('📊 Response:', testResult);
|
||||
} else {
|
||||
console.log('❌ Connection failed');
|
||||
console.log('🔍 Error details:', testResult);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('💥 Unexpected error:', error.message);
|
||||
if (error.response) {
|
||||
console.log('📜 Response status:', error.response.status);
|
||||
console.log('📜 Response data:', error.response.data);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🏁 Test completed');
|
||||
}
|
||||
|
||||
testBearerAuth();
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Sample data for testing Ordrestyring integration
|
||||
-- Run this to create test data for development
|
||||
|
||||
INSERT INTO imported_quotes (
|
||||
external_id, external_case_number, customer_name, customer_email,
|
||||
customer_phone, customer_address, project_name, project_description,
|
||||
total_amount_excl_vat, total_amount_incl_vat, currency, status,
|
||||
case_type, valid_until, created_at, updated_at, source, imported_at
|
||||
) VALUES
|
||||
(1001, 'CASE-2025-001', 'Hans Hansen', '[email protected]', '21412544', 'Hansegade 12, 4600 Køge',
|
||||
'Kunda a/s Tagrenovering', 'Udskiftning af b7 tag til nyt b7 tag på erhvervsbygning',
|
||||
134662.50, 168328.13, 'DKK', 'Accepteret', 'Tagrenovering', '2025-10-17',
|
||||
'2025-08-15 10:30:00', '2025-09-15 14:22:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1002, 'CASE-2025-002', 'Karen Andersen', '[email protected]', '23456789', 'Vestergade 45, 4000 Roskilde',
|
||||
'Villa Vinduesudskiftning', 'Udskiftning af 12 vinduer i 1-plans villa',
|
||||
85000.00, 106250.00, 'DKK', 'Under behandling', 'Vinduer og døre', '2025-11-01',
|
||||
'2025-08-20 09:15:00', '2025-09-10 16:45:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1003, 'CASE-2025-003', 'Peter Nielsen', '[email protected]', '87654321', 'Nygade 8, 4180 Sorø',
|
||||
'Terrasse og carport', 'Nyt træ terrasse 40m² og carport til 2 biler',
|
||||
125000.00, 156250.00, 'DKK', 'Sendt tilbud', 'Terrasser og carporte', '2025-10-30',
|
||||
'2025-09-01 11:20:00', '2025-09-12 13:15:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1004, 'CASE-2025-004', 'Lene Larsen', '[email protected]', '45678901', 'Birkevej 23, 4700 Næstved',
|
||||
'Køkken totalrenovering', 'Komplet køkken med skabsløsninger i massiv eg',
|
||||
180000.00, 225000.00, 'DKK', 'Accepteret', 'Køkken og bad', '2025-11-15',
|
||||
'2025-08-25 14:45:00', '2025-09-08 10:30:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1005, 'CASE-2025-005', 'Thomas Thomsen', '[email protected]', '56789012', 'Skovvej 67, 4600 Køge',
|
||||
'Tilbygning til villa', 'Nyt værelse og bad - 25m² tilbygning',
|
||||
220000.00, 275000.00, 'DKK', 'Under behandling', 'Tilbygninger', '2025-12-01',
|
||||
'2025-09-05 16:00:00', '2025-09-14 12:20:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1006, 'CASE-2025-006', 'Marie Madsen', '[email protected]', '67890123', 'Østergade 34, 4000 Roskilde',
|
||||
'Badeværelse renovering', 'Komplet badeværelse med fliser og VVS',
|
||||
95000.00, 118750.00, 'DKK', 'Sendt tilbud', 'Køkken og bad', '2025-10-25',
|
||||
'2025-08-30 08:30:00', '2025-09-16 15:45:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1007, 'CASE-2025-007', 'Jakob Jensen', '[email protected]', '78901234', 'Fredensgade 12, 4180 Sorø',
|
||||
'Entreprise rækkehus', 'Fuld renovering af rækkehus inkl. tag og facade',
|
||||
450000.00, 562500.00, 'DKK', 'Afvist', 'Renovering', '2025-09-30',
|
||||
'2025-07-15 13:20:00', '2025-08-20 09:10:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1008, 'CASE-2025-008', 'Susanne Sørensen', '[email protected]', '89012345', 'Hovedgade 89, 4700 Næstved',
|
||||
'Velux vinduer installation', 'Installation af 4 Velux tagvinduer',
|
||||
65000.00, 81250.00, 'DKK', 'Accepteret', 'Tagvinduer', '2025-11-10',
|
||||
'2025-09-10 11:45:00', '2025-09-17 14:30:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1009, 'CASE-2025-009', 'Michael Mikkelsen', '[email protected]', '90123456', 'Strandvej 56, 4600 Køge',
|
||||
'Sommerhus renovering', 'Udskiftning af gulve og maling af vægge',
|
||||
75000.00, 93750.00, 'DKK', 'Under behandling', 'Renovering', '2025-11-20',
|
||||
'2025-09-12 10:15:00', '2025-09-16 16:20:00', 'ordrestyring', NOW()),
|
||||
|
||||
(1010, 'CASE-2025-010', 'Anne Andersen', '[email protected]', '01234567', 'Parkvej 78, 4000 Roskilde',
|
||||
'Garage og redskabsskur', 'Nyt dobbelt garage med tilbygget redskabsskur',
|
||||
165000.00, 206250.00, 'DKK', 'Sendt tilbud', 'Garager og skure', '2025-12-05',
|
||||
'2025-09-14 12:30:00', '2025-09-17 11:45:00', 'ordrestyring', NOW());
|
||||
|
||||
-- Add some older data for trend analysis
|
||||
INSERT INTO imported_quotes (
|
||||
external_id, external_case_number, customer_name, customer_email,
|
||||
customer_phone, customer_address, project_name, project_description,
|
||||
total_amount_excl_vat, total_amount_incl_vat, currency, status,
|
||||
case_type, valid_until, created_at, updated_at, source, imported_at
|
||||
) VALUES
|
||||
(2001, 'CASE-2024-045', 'Gamle Hansen', '[email protected]', '11111111', 'Gammel Vej 1, 4600 Køge',
|
||||
'Historisk projekt 2024', 'Afsluttet projekt fra sidste år',
|
||||
150000.00, 187500.00, 'DKK', 'Accepteret', 'Tagrenovering', '2024-12-31',
|
||||
'2024-11-15 10:00:00', '2024-12-20 15:30:00', 'ordrestyring', NOW()),
|
||||
|
||||
(2002, 'CASE-2024-046', 'Tidigare Andersen', '[email protected]', '22222222', 'Forrige Gade 2, 4000 Roskilde',
|
||||
'Sidste års vinduer', 'Vinduer fra forrige sæson',
|
||||
95000.00, 118750.00, 'DKK', 'Accepteret', 'Vinduer og døre', '2024-12-15',
|
||||
'2024-10-20 14:20:00', '2024-11-25 11:10:00', 'ordrestyring', NOW());
|
||||
+113
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"id": 31,
|
||||
"project_name": "Kunda a/s",
|
||||
"customer_name": "Hans Hansen",
|
||||
"customer_email": "[email protected]",
|
||||
"customer_phone": "21412544",
|
||||
"customer_address": "hansegade 12",
|
||||
"project_description": "udskiftning af b7 tag til nyt b7 tag",
|
||||
"project_status": "calculation_ready",
|
||||
"created_at": "2025-09-17T07:12:49.000Z",
|
||||
"updated_at": "2025-09-17T07:50:53.000Z",
|
||||
"project_type_id": null,
|
||||
"geometry_count": 1,
|
||||
"labor_count": 1,
|
||||
"materials_count": 1,
|
||||
"quotes_count": 3
|
||||
}
|
||||
{
|
||||
"success": true,
|
||||
"geometry": {
|
||||
"id": 46,
|
||||
"project_id": 31,
|
||||
"roof_type": "skraat_tag",
|
||||
"total_area": "342.24",
|
||||
"roof_pitch": "45.00",
|
||||
"roof_height": "11.00",
|
||||
"complexity_factor": "1.30",
|
||||
"length_main": "11.00",
|
||||
"width_main": "22.00",
|
||||
"has_dormers": 0,
|
||||
"has_chimneys": 0,
|
||||
"has_skylights": 0,
|
||||
"access_difficulty": "medium",
|
||||
"estimated_work_hours": null,
|
||||
"estimated_carpenters": null,
|
||||
"notes": "",
|
||||
"created_at": "2025-09-17T08:18:32.000Z",
|
||||
"updated_at": "2025-09-17T08:18:32.000Z",
|
||||
"roof_type_id": null
|
||||
}
|
||||
}
|
||||
{
|
||||
"success": true,
|
||||
"labor": [
|
||||
{
|
||||
"id": 31,
|
||||
"project_id": 31,
|
||||
"carpenter_count": 2,
|
||||
"estimated_hours_per_carpenter": "1.00",
|
||||
"total_work_hours": "2.00",
|
||||
"hourly_rate": "580.00",
|
||||
"total_labor_cost": "1160.00",
|
||||
"work_breakdown": "[{\"task\":\"Nedrivning af eksisterende\",\"hours\":2,\"rate\":580,\"cost\":1160,\"notes\":\"Fjernelse af gamle materialer\"}]",
|
||||
"allocated_hours_breakdown": null,
|
||||
"special_conditions": null,
|
||||
"notes": "1 arbejdsopgaver registreret",
|
||||
"created_at": "2025-09-17T08:18:39.000Z",
|
||||
"updated_at": "2025-09-17T08:18:39.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"success": true,
|
||||
"materials": [
|
||||
{
|
||||
"id": 34,
|
||||
"project_id": 31,
|
||||
"material_name": "B7 tagplader",
|
||||
"material_category": "tagmaterialer",
|
||||
"quantity": "1.000",
|
||||
"unit": "m2",
|
||||
"unit_price": "285.00",
|
||||
"total_price": "285.00",
|
||||
"supplier": "Bygma",
|
||||
"material_source": "manual",
|
||||
"notes": "Fra database - tagmaterialer",
|
||||
"created_at": "2025-09-17T07:13:46.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"company_info": {
|
||||
"name": "Tømrer- og Snedkermester Mikael Holck ApS",
|
||||
"phone": "42468110",
|
||||
"email": "[email protected]",
|
||||
"website": "www.mikaelholck.dk",
|
||||
"cvr": "38178059",
|
||||
"address": "Hornumvej 7",
|
||||
"city": "4600 Køge"
|
||||
},
|
||||
"project": {
|
||||
"customer_name": "Hans Hansen",
|
||||
"project_name": "Kunda a/s",
|
||||
"project_description": "udskiftning af b7 tag til nyt b7 tag"
|
||||
},
|
||||
"geometry": {
|
||||
"roof_type": "skraat_tag",
|
||||
"total_area": 342.24
|
||||
},
|
||||
"labor": {
|
||||
"total_work_hours": 205,
|
||||
"carpenter_count": 2,
|
||||
"hourly_rate": 580
|
||||
},
|
||||
"materials": [
|
||||
{
|
||||
"material_name": "Tagplader B7",
|
||||
"quantity": 350,
|
||||
"unit": "m²",
|
||||
"unit_price": 85,
|
||||
"total_price": 29750
|
||||
},
|
||||
{
|
||||
"material_name": "Taglægter C18",
|
||||
"quantity": 50,
|
||||
"unit": "stk",
|
||||
"unit_price": 45,
|
||||
"total_price": 2250
|
||||
}
|
||||
],
|
||||
"calculation": {
|
||||
"total_labor_cost": 118900,
|
||||
"total_material_cost": 32000,
|
||||
"subtotal": 150900,
|
||||
"vat_amount": 37725,
|
||||
"total_incl_vat": 188625
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"project": {
|
||||
"id": 31,
|
||||
"project_name": "Kunda a/s",
|
||||
"customer_name": "Hans Hansen",
|
||||
"customer_email": "[email protected]",
|
||||
"customer_phone": "21412544",
|
||||
"customer_address": "hansegade 12",
|
||||
"project_description": "udskiftning af b7 tag til nyt b7 tag"
|
||||
},
|
||||
"geometry": {
|
||||
"roof_type": "skraat_tag",
|
||||
"total_area": 342.24,
|
||||
"roof_pitch": 25,
|
||||
"complexity_factor": 1.2
|
||||
},
|
||||
"labor": {
|
||||
"carpenter_count": 2,
|
||||
"total_work_hours": 82,
|
||||
"allocated_hours": 75,
|
||||
"hourly_rate": 580
|
||||
},
|
||||
"materials": [
|
||||
{
|
||||
"material_name": "B7 Tagplader",
|
||||
"quantity": 350,
|
||||
"unit": "m²",
|
||||
"unit_price": 125.50,
|
||||
"total_price": 43925.00,
|
||||
"supplier": "Bygma",
|
||||
"category": "tagmaterialer"
|
||||
},
|
||||
{
|
||||
"material_name": "C18 Taglægter",
|
||||
"quantity": 85,
|
||||
"unit": "stk",
|
||||
"unit_price": 45.00,
|
||||
"total_price": 3825.00,
|
||||
"supplier": "Bygma",
|
||||
"category": "tagmaterialer"
|
||||
},
|
||||
{
|
||||
"material_name": "Tagrender 125mm",
|
||||
"quantity": 24,
|
||||
"unit": "m",
|
||||
"unit_price": 185.00,
|
||||
"total_price": 4440.00,
|
||||
"supplier": "Bygma",
|
||||
"category": "tagmaterialer"
|
||||
}
|
||||
],
|
||||
"calculation": {
|
||||
"total_labor_cost": 47560.00,
|
||||
"total_material_cost": 52190.00,
|
||||
"subtotal": 99750.00,
|
||||
"overhead_percentage": 15,
|
||||
"overhead_amount": 14962.50,
|
||||
"profit_percentage": 20,
|
||||
"profit_amount": 19950.00,
|
||||
"total_excl_vat": 134662.50,
|
||||
"vat_percentage": 25,
|
||||
"vat_amount": 33665.63,
|
||||
"total_incl_vat": 168328.13
|
||||
}
|
||||
}
|
||||
+467
-346
@@ -81,7 +81,7 @@ const csvUpload = multer({
|
||||
});
|
||||
|
||||
// Initialize backend services
|
||||
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService;
|
||||
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, ordrestyringService;
|
||||
|
||||
const initializeBackend = async () => {
|
||||
try {
|
||||
@@ -95,9 +95,12 @@ const initializeBackend = async () => {
|
||||
const OrderStatusService = require('./backend/src/services/orderStatusService');
|
||||
const QuoteTemplateService = require('./backend/src/services/quoteTemplateService');
|
||||
const PdfGenerationService = require('./backend/src/services/pdfGenerationService');
|
||||
const OrdrestyringService = require('./backend/src/services/ordrestyringService');
|
||||
|
||||
dynamicImportService = new DynamicImportService(databaseService);
|
||||
orderStatusService = new OrderStatusService();
|
||||
ordrestyringService = new OrdrestyringService();
|
||||
orderStatusService = new OrderStatusService();
|
||||
quoteTemplateService = new QuoteTemplateService();
|
||||
pdfGenerationService = new PdfGenerationService();
|
||||
|
||||
@@ -3052,343 +3055,116 @@ app.post('/api/customer-projects/:projectId/quote', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get related data for quote generation
|
||||
const geometry = await databaseService.query(
|
||||
'SELECT * FROM roof_geometry WHERE project_id = ?',
|
||||
// Get latest calculation for this project
|
||||
const [calculation] = await databaseService.query(
|
||||
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const labor = await databaseService.query(
|
||||
'SELECT * FROM project_labor WHERE project_id = ?',
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const materials = await databaseService.query(
|
||||
'SELECT * FROM project_materials WHERE project_id = ?',
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
const laborTotal = labor.reduce((sum, item) => sum + parseFloat(item.total_cost || 0), 0);
|
||||
const materialsTotal = materials.reduce((sum, item) => sum + parseFloat(item.total_price || 0), 0);
|
||||
const subtotal = laborTotal + materialsTotal;
|
||||
const vat = subtotal * 0.25; // 25% Danish VAT
|
||||
const total = subtotal + vat;
|
||||
|
||||
// Company information configuration - easy to customize
|
||||
const COMPANY_INFO = {
|
||||
name: "Tømrer- og Snedkermester Mikael Holck ApS",
|
||||
phone: "42468110",
|
||||
cvr: "38178059",
|
||||
address: "Hornumvej 7",
|
||||
city: "4600 Køge",
|
||||
bank: "Nordea",
|
||||
email: "[email protected]",
|
||||
account: "2558 6284200630",
|
||||
website: "www.mikaelholck.dk"
|
||||
};
|
||||
|
||||
// Professional quote generator matching exact PDF format
|
||||
function generateProfessionalQuote(data) {
|
||||
const { project, geometry, labor, materials, totals } = data;
|
||||
const currentDate = new Date().toLocaleDateString('da-DK');
|
||||
|
||||
// Generate a quote number (timestamp-based)
|
||||
const quoteNumber = `${Date.now().toString().slice(-4)}`;
|
||||
|
||||
// Format materials list to match PDF style
|
||||
const formattedMaterials = materials.map(m => {
|
||||
const description = m.material_name;
|
||||
const amount = (parseFloat(m.unit_price) * parseFloat(m.quantity)).toFixed(2);
|
||||
return {
|
||||
description: description,
|
||||
amount: `${parseFloat(amount).toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`
|
||||
};
|
||||
if (!calculation) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ingen prisberegning fundet for dette projekt. Lav en prisberegning først.'
|
||||
});
|
||||
|
||||
// Add labor items in same format
|
||||
const laborItems = [];
|
||||
if (labor && labor.length > 0) {
|
||||
const workDescription = geometry?.roof_type === 'skraat_tag' ? 'Tag m²' : 'Arbejdsløn';
|
||||
laborItems.push({
|
||||
description: workDescription,
|
||||
amount: totals.laborTotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})
|
||||
});
|
||||
}
|
||||
|
||||
// Combine all items
|
||||
const allItems = [...laborItems, ...formattedMaterials];
|
||||
|
||||
// Create itemized list for the quote
|
||||
const itemizedList = allItems.map(item =>
|
||||
`${item.description.padEnd(50)} ${item.amount.padStart(12)}`
|
||||
).join('\n');
|
||||
|
||||
// Format customer address properly
|
||||
const customerAddress = project.project_address || 'Kunde adresse';
|
||||
|
||||
return `TILBUD
|
||||
|
||||
Tilbudnr. .......... ${quoteNumber}
|
||||
Tilbudsdato ........ ${currentDate}
|
||||
Kundenr. ........... ${project.id}
|
||||
Side ............... 1/1
|
||||
|
||||
Rekvirent: ${project.customer_name}
|
||||
Reference:
|
||||
${customerAddress}
|
||||
|
||||
${project.project_name}
|
||||
|
||||
${project.project_description || 'Renovering og vedligeholdelse'}
|
||||
|
||||
Stillads opstilles til opgaven
|
||||
${geometry?.roof_type === 'skraat_tag' ? 'Eksisterende tagmaterialer demonteres' : ''}
|
||||
${materials.some(m => m.material_name?.toLowerCase().includes('lægte')) ? 'Lægter demonteres, og nye godkendte T1 taglægter monteres.' : ''}
|
||||
${materials.some(m => m.material_name?.toLowerCase().includes('tagplade')) ? 'Nye tagplader leveres og monteres efter forskrifter.' : ''}
|
||||
${materials.some(m => m.material_name?.toLowerCase().includes('rende')) ? 'Tagrender udskiftes til nye inkl. rendejern og nedløb.' : ''}
|
||||
${geometry ? `Areal: ${geometry.total_area} m²` : ''}
|
||||
|
||||
Varebeskrivelse Beløb
|
||||
${itemizedList}
|
||||
|
||||
Momsifrit beløb: 0,00 - Momspligtigt beløb: ${totals.subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})} Subtotal: ${totals.subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
|
||||
25,00% Moms: ${totals.vat.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
|
||||
Total DKK: ${totals.total.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
|
||||
|
||||
${COMPANY_INFO.name} Tlf.: ${COMPANY_INFO.phone} cvr: ${COMPANY_INFO.cvr}
|
||||
${COMPANY_INFO.address} Bank: ${COMPANY_INFO.bank} mail: ${COMPANY_INFO.email}
|
||||
${COMPANY_INFO.city} Kontonr.: ${COMPANY_INFO.account} ${COMPANY_INFO.website}
|
||||
|
||||
|
||||
--- Side 2 ---
|
||||
|
||||
Med mange års erfaring inden for snedker- og tømrerarbejde kan vi udføre en bred vifte af
|
||||
opgaver — Store som små. Vi brænder for at realisere drømme, og har gennem årene hjulpet
|
||||
privatpersoner, virksomheder og offentlige institutioner i mål med deres drømme.
|
||||
|
||||
Som professionelt tømrerfirma værner vi omkring høj kvalitet og god service. Vi er af den
|
||||
overbevisning at de bedste resultater skabes gennem tæt kunde. Derfor bliver dine ønsker og
|
||||
behov altid hørt hos os. Ud fra disse, og med vores brede faglighed, finder vi sammen den
|
||||
bedste løsning til den absolut bedste pris.
|
||||
|
||||
Hos ${COMPANY_INFO.name} går vi aldrig på kompromis med kvaliteten af
|
||||
vores arbejde. Hertil er vi medlem af Byg Garanti, hvilket er din garanti for veludført
|
||||
håndværk. Hos os er du en prioritet, og ikke bare endnu en ordre i kalenderen. Vi kommer
|
||||
derfor altid til tiden, og rydder selvfølgelig op efter os selv, når opgaven er udført.
|
||||
|
||||
Vores tid er vigtig
|
||||
Vi bruger hver dag mange timer på at regne tilbud, og derfor vil vi sætte stor pris på at du
|
||||
vender tilbage med feedback på det tilbud vi har givet dig.
|
||||
|
||||
Det er vigtigt for os med din feedback, og derfor vender vi også selv tilbage hvis vi ikke har
|
||||
hørt noget fra dig. Dette gør vi for at yde den bedste service til alle vi er i dialog med.
|
||||
|
||||
Husk at det "bedste tilbud" ikke altid er billigste. Det skal udføres korrekt og til den rigtige tid.
|
||||
Du skal kunne stole på din tømrer såvel som snedker — Og det kan du hos os.
|
||||
|
||||
Anmeld Håndværker
|
||||
Vi er medlem af Anmeld Håndværker, og der har vi mærket "ELITE HÅNDVÆRKER" på
|
||||
baggrund af vores mange gode anmeldelser.
|
||||
|
||||
På Anmeld håndværker har du mulighed for at se anmeldelser på opgaver vi har udført.
|
||||
Læs vores anmeldelser her: Anmeld Håndværker
|
||||
|
||||
${COMPANY_INFO.name} Tlf.: ${COMPANY_INFO.phone} cvr: ${COMPANY_INFO.cvr}
|
||||
${COMPANY_INFO.address} Bank: ${COMPANY_INFO.bank} mail: ${COMPANY_INFO.email}
|
||||
${COMPANY_INFO.city} Kontonr.: ${COMPANY_INFO.account} ${COMPANY_INFO.website}`;
|
||||
}
|
||||
|
||||
// Simple static quote generator (legacy format)
|
||||
function generateSimpleStaticQuote(data) {
|
||||
const { project, geometry, labor, materials, totals } = data;
|
||||
const currentDate = new Date().toLocaleDateString('da-DK');
|
||||
|
||||
const materialsList = materials.map(m =>
|
||||
`• ${m.material_name}: ${m.quantity} ${m.unit} à ${m.unit_price} kr = ${m.total_price} kr`
|
||||
).join('\n');
|
||||
|
||||
return `TILBUD FRA TILBUDGIVERN
|
||||
|
||||
Til: ${project.customer_name}
|
||||
Projekt: ${project.project_name}
|
||||
Dato: ${currentDate}
|
||||
|
||||
Kære ${project.customer_name},
|
||||
|
||||
Vi sender hermed tilbud på ${project.project_name}.
|
||||
|
||||
PROJEKTDETALJER:
|
||||
${geometry ? `• Tag type: ${geometry.roof_type}` : ''}
|
||||
${geometry ? `• Total areal: ${geometry.total_area} m²` : ''}
|
||||
${project.project_description ? `• Beskrivelse: ${project.project_description}` : ''}
|
||||
|
||||
ARBEJDE:
|
||||
${labor && labor.length > 0 ? `• Tømrerarbejde: ${labor[0].total_work_hours} timer à ${labor[0].hourly_rate} kr/time` : ''}
|
||||
${labor && labor.length > 0 ? `• Antal tømrere: ${labor[0].carpenter_count}` : ''}
|
||||
|
||||
MATERIALER:
|
||||
${materialsList}
|
||||
|
||||
PRISSAMMENDRAG:
|
||||
Arbejdsløn: ${totals.laborTotal.toLocaleString('da-DK')} kr
|
||||
Materialer: ${totals.materialsTotal.toLocaleString('da-DK')} kr
|
||||
Subtotal: ${totals.subtotal.toLocaleString('da-DK')} kr
|
||||
Moms (25%): ${totals.vat.toLocaleString('da-DK')} kr
|
||||
TOTAL: ${totals.total.toLocaleString('da-DK')} kr
|
||||
|
||||
Tilbuddet er gældende i 30 dage.
|
||||
|
||||
Med venlig hilsen,
|
||||
Tilbudgivern ApS`;
|
||||
}
|
||||
|
||||
// Generate quote using simplified approach
|
||||
let quote;
|
||||
let quoteText; // Declare at function scope
|
||||
// Use the proper ProjectQuoteGenerationService
|
||||
let result;
|
||||
|
||||
if (quoteType === 'ai' && openaiService) {
|
||||
try {
|
||||
// Use OpenAI service directly for AI quotes
|
||||
quote = await openaiService.generateQuote({
|
||||
project,
|
||||
geometry: geometry[0] || null,
|
||||
labor,
|
||||
materials,
|
||||
totals: {
|
||||
laborTotal,
|
||||
materialsTotal,
|
||||
subtotal,
|
||||
vat,
|
||||
total
|
||||
}
|
||||
});
|
||||
quoteText = quote.quote || quote.text || quote;
|
||||
} catch (aiError) {
|
||||
console.error('AI quote generation failed, falling back to professional format:', aiError);
|
||||
// Fallback to professional quote if AI fails
|
||||
quote = generateProfessionalQuote({
|
||||
project,
|
||||
geometry: geometry[0] || null,
|
||||
labor,
|
||||
materials,
|
||||
totals: {
|
||||
laborTotal,
|
||||
materialsTotal,
|
||||
subtotal,
|
||||
vat,
|
||||
total
|
||||
}
|
||||
});
|
||||
quoteText = quote;
|
||||
}
|
||||
} else {
|
||||
// Generate professional quote matching PDF format
|
||||
quote = generateProfessionalQuote({
|
||||
project,
|
||||
geometry: geometry[0] || null,
|
||||
labor,
|
||||
materials,
|
||||
totals: {
|
||||
laborTotal,
|
||||
materialsTotal,
|
||||
subtotal,
|
||||
vat,
|
||||
total
|
||||
}
|
||||
});
|
||||
quoteText = quote;
|
||||
}
|
||||
|
||||
// Ensure quoteText is defined and not empty
|
||||
if (!quoteText || typeof quoteText !== 'string') {
|
||||
throw new Error('Quote generation failed - empty or invalid quote text');
|
||||
}
|
||||
|
||||
// Prepare quote data for saving
|
||||
const quoteData = {
|
||||
project,
|
||||
geometry: geometry[0] || null,
|
||||
labor,
|
||||
materials,
|
||||
totals: {
|
||||
laborTotal,
|
||||
materialsTotal,
|
||||
subtotal,
|
||||
vat,
|
||||
total
|
||||
}
|
||||
};
|
||||
|
||||
// Create a basic calculation record first (required for foreign key)
|
||||
let calculationId;
|
||||
try {
|
||||
const calculationResult = await databaseService.query(`
|
||||
INSERT INTO project_calculations (
|
||||
project_id, total_area, total_work_hours, carpenter_count,
|
||||
total_labor_cost, total_material_cost, material_count,
|
||||
subtotal, overhead_amount, profit_amount, total_excl_vat,
|
||||
vat_amount, total_incl_vat, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [
|
||||
projectId,
|
||||
geometry && geometry[0] ? geometry[0].total_area : 0,
|
||||
labor && labor.length > 0 ? labor[0].total_work_hours : 0,
|
||||
labor && labor.length > 0 ? labor[0].carpenter_count : 1,
|
||||
laborTotal,
|
||||
materialsTotal,
|
||||
materials.length,
|
||||
subtotal,
|
||||
subtotal * 0.15, // 15% overhead
|
||||
subtotal * 0.20, // 20% profit
|
||||
subtotal * 1.35, // subtotal + overhead + profit
|
||||
vat,
|
||||
total
|
||||
]);
|
||||
|
||||
calculationId = calculationResult.insertId;
|
||||
console.log(`✅ Created calculation record with ID: ${calculationId}`);
|
||||
} catch (calcError) {
|
||||
console.error('Error creating calculation record:', calcError);
|
||||
throw new Error(`Failed to create calculation: ${calcError.message}`);
|
||||
if (quoteType === 'ai' && openaiService && openaiService.openai) {
|
||||
// Use AI quote generation with proper data
|
||||
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
|
||||
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
|
||||
|
||||
result = await projectQuoteService.generateProfessionalQuote(projectId, calculation.id, {
|
||||
quoteStyle: 'professional',
|
||||
includeBreakdown: true,
|
||||
language: 'danish'
|
||||
});
|
||||
} else {
|
||||
// Use static quote generation (fallback if no AI available)
|
||||
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
|
||||
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
|
||||
|
||||
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
|
||||
}
|
||||
} catch (aiError) {
|
||||
console.log('AI quote generation failed, falling back to static:', aiError.message);
|
||||
// Fallback to static quote if AI fails
|
||||
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
|
||||
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, null); // No AI service
|
||||
|
||||
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
|
||||
}
|
||||
|
||||
// Save quote to database (without PDF initially)
|
||||
try {
|
||||
const result = await databaseService.query(`
|
||||
INSERT INTO generated_quotes (
|
||||
project_id, calculation_id, quote_text, quote_format,
|
||||
ai_tokens_used, ai_cost, ai_model, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
`, [
|
||||
projectId,
|
||||
calculationId, // Use the created calculation ID
|
||||
quoteText,
|
||||
'text', // Start as text, can generate PDF later
|
||||
quoteType === 'ai' ? (quote && quote.tokens ? quote.tokens : 100) : 0,
|
||||
quoteType === 'ai' ? (quote && quote.cost ? quote.cost : 0.01) : 0.00,
|
||||
quoteType === 'ai' ? 'gpt-4' : 'static'
|
||||
]);
|
||||
|
||||
console.log(`✅ Created quote record with ID: ${result.insertId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
quoteId: result.insertId,
|
||||
calculationId: calculationId,
|
||||
quote: quoteText,
|
||||
message: 'Tilbud genereret succesfuldt'
|
||||
});
|
||||
} catch (quoteError) {
|
||||
console.error('Error saving quote to database:', quoteError);
|
||||
throw new Error(`Failed to save quote: ${quoteError.message}`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
quoteId: result.quoteId,
|
||||
quoteText: result.quoteText,
|
||||
tokensUsed: result.tokensUsed || 0,
|
||||
cost: result.cost || 0,
|
||||
method: quoteType === 'ai' ? 'ai' : 'static'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating quote:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fejl ved generering af tilbud: ${error.message}`
|
||||
error: error.message || 'Fejl ved generering af tilbud'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get work hour statistics for AI estimation
|
||||
app.get('/api/work-hour-statistics/:roofType', async (req, res) => {
|
||||
try {
|
||||
const { roofType } = req.params;
|
||||
const { areaMin, areaMax } = req.query;
|
||||
|
||||
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
|
||||
const roofService = new RoofGeometryService(databaseService);
|
||||
|
||||
const areaRange = areaMin && areaMax ? [parseFloat(areaMin), parseFloat(areaMax)] : undefined;
|
||||
const statistics = await roofService.getWorkHourStatistics(roofType, areaRange);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting work hour statistics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate work hours using AI/historical data
|
||||
app.post('/api/estimate-work-hours', async (req, res) => {
|
||||
try {
|
||||
const projectData = req.body;
|
||||
|
||||
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
|
||||
const roofService = new RoofGeometryService(databaseService);
|
||||
|
||||
const estimatedHours = await roofService.estimateWorkHoursWithAI(projectData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
estimatedHours,
|
||||
method: 'ai_historical'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error estimating work hours:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3502,7 +3278,89 @@ app.get('/api/quotes/:quoteId/pdf', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate PDF for existing quote
|
||||
// PDF generation service with Python backend
|
||||
class PythonPDFService {
|
||||
constructor() {
|
||||
this.tempDir = '/tmp/tilbudgivern-pdfs';
|
||||
this.ensureTempDir();
|
||||
}
|
||||
|
||||
ensureTempDir() {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async generatePDF(quoteData, outputPath) {
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create temporary JSON file
|
||||
const tempJsonPath = path.join(this.tempDir, `quote_${Date.now()}.json`);
|
||||
|
||||
try {
|
||||
// Write quote data to temporary JSON file
|
||||
fs.writeFileSync(tempJsonPath, JSON.stringify(quoteData, null, 2));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const pythonProcess = spawn('python3', [
|
||||
'/home/alex/git/tilbudgivern/backend/pdf_generator.py',
|
||||
tempJsonPath,
|
||||
outputPath
|
||||
]);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
pythonProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.on('close', (code) => {
|
||||
// Cleanup temp JSON file
|
||||
try {
|
||||
fs.unlinkSync(tempJsonPath);
|
||||
} catch (e) {
|
||||
console.warn('Could not delete temp file:', tempJsonPath);
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
console.log('✅ PDF generated successfully:', stdout.trim());
|
||||
resolve({
|
||||
success: true,
|
||||
message: stdout.trim(),
|
||||
outputPath
|
||||
});
|
||||
} else {
|
||||
console.error('❌ PDF generation failed:', stderr);
|
||||
reject(new Error(`PDF generation failed: ${stderr || 'Unknown error'}`));
|
||||
}
|
||||
});
|
||||
|
||||
pythonProcess.on('error', (error) => {
|
||||
console.error('Failed to start Python PDF generator:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// Cleanup temp file on error
|
||||
try {
|
||||
fs.unlinkSync(tempJsonPath);
|
||||
} catch (e) {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pythonPDFService = new PythonPDFService();
|
||||
|
||||
// Generate PDF for existing quote
|
||||
app.post('/api/quotes/:quoteId/generate-pdf', async (req, res) => {
|
||||
try {
|
||||
const { quoteId } = req.params;
|
||||
@@ -3546,41 +3404,132 @@ app.post('/api/quotes/:quoteId/generate-pdf', async (req, res) => {
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// Calculate totals from existing calculation
|
||||
const totals = {
|
||||
laborTotal: parseFloat(quote.total_labor_cost || 0),
|
||||
materialsTotal: parseFloat(quote.total_material_cost || 0),
|
||||
subtotal: parseFloat(quote.subtotal || 0),
|
||||
vat: parseFloat(quote.vat_amount || 0),
|
||||
total: parseFloat(quote.total_incl_vat || 0)
|
||||
// Prepare data for Python PDF generator
|
||||
const pdfData = {
|
||||
project: project,
|
||||
geometry: geometry[0] || null,
|
||||
labor: labor[0] || null,
|
||||
materials: materials,
|
||||
calculation: {
|
||||
total_labor_cost: parseFloat(quote.total_labor_cost || 0),
|
||||
total_material_cost: parseFloat(quote.total_material_cost || 0),
|
||||
subtotal: parseFloat(quote.subtotal || 0),
|
||||
vat_amount: parseFloat(quote.vat_amount || 0),
|
||||
total_incl_vat: parseFloat(quote.total_incl_vat || 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Generate PDF
|
||||
const pdfBuffer = await pdfGenerationService.generateQuotePdf({
|
||||
project,
|
||||
geometry: geometry[0] || null,
|
||||
labor,
|
||||
materials,
|
||||
totals
|
||||
// Generate PDF using Python script (fast!)
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Write data to temp JSON file
|
||||
const tempJsonPath = `/tmp/quote_data_${quoteId}_${Date.now()}.json`;
|
||||
const tempPdfPath = `/tmp/quote_${quoteId}_${Date.now()}.pdf`;
|
||||
|
||||
fs.writeFileSync(tempJsonPath, JSON.stringify(pdfData, null, 2));
|
||||
|
||||
// Run Python PDF generator
|
||||
const pythonProcess = spawn('python3', [
|
||||
path.join(__dirname, 'backend/pdf_generator.py'),
|
||||
tempJsonPath,
|
||||
tempPdfPath
|
||||
]);
|
||||
|
||||
let pythonOutput = '';
|
||||
let pythonError = '';
|
||||
|
||||
pythonProcess.stdout.on('data', (data) => {
|
||||
pythonOutput += data.toString();
|
||||
});
|
||||
|
||||
// Update database with new PDF
|
||||
await databaseService.query(
|
||||
'UPDATE generated_quotes SET pdf_data = ?, quote_format = ? WHERE id = ?',
|
||||
[pdfBuffer, 'pdf', quoteId]
|
||||
);
|
||||
pythonProcess.stderr.on('data', (data) => {
|
||||
pythonError += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.on('close', async (code) => {
|
||||
try {
|
||||
// Clean up temp JSON file
|
||||
fs.unlinkSync(tempJsonPath);
|
||||
|
||||
if (code === 0 && fs.existsSync(tempPdfPath)) {
|
||||
// Read PDF file and store in database
|
||||
const pdfBuffer = fs.readFileSync(tempPdfPath);
|
||||
|
||||
// Update database with PDF
|
||||
await databaseService.query(
|
||||
'UPDATE generated_quotes SET pdf_data = ?, quote_format = ? WHERE id = ?',
|
||||
[pdfBuffer, 'pdf', quoteId]
|
||||
);
|
||||
|
||||
// Clean up temp PDF file
|
||||
fs.unlinkSync(tempPdfPath);
|
||||
|
||||
console.log(`✅ PDF generated successfully for quote ${quoteId}`);
|
||||
console.log(`📊 Python output: ${pythonOutput}`);
|
||||
} else {
|
||||
console.error(`❌ PDF generation failed for quote ${quoteId}, exit code: ${code}`);
|
||||
console.error(`📊 Python output: ${pythonOutput}`);
|
||||
console.error(`📊 Python error: ${pythonError}`);
|
||||
console.error(`📊 Temp PDF exists: ${fs.existsSync(tempPdfPath)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in PDF generation cleanup:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Send immediate success response (Python is fast enough)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'PDF genereret og gemt succesfuldt',
|
||||
message: 'PDF genereret succesfuldt!',
|
||||
quoteId: quoteId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF for existing quote:', error);
|
||||
console.error('Error generating PDF:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fejl ved generering af PDF: ${error.message}`
|
||||
error: 'Fejl ved PDF generering: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check PDF generation status for a quote
|
||||
app.get('/api/quotes/:quoteId/pdf-status', async (req, res) => {
|
||||
try {
|
||||
const { quoteId } = req.params;
|
||||
|
||||
const [quote] = await databaseService.query(
|
||||
'SELECT quote_format, pdf_data, notes FROM generated_quotes WHERE id = ?',
|
||||
[quoteId]
|
||||
);
|
||||
|
||||
if (!quote) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Tilbud ikke fundet'
|
||||
});
|
||||
}
|
||||
|
||||
const hasPDF = quote.quote_format === 'pdf' && quote.pdf_data !== null;
|
||||
const hasError = quote.notes && quote.notes.includes('PDF generation failed');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hasPDF: hasPDF,
|
||||
status: hasError ? 'failed' : (hasPDF ? 'completed' : 'pending'),
|
||||
format: quote.quote_format,
|
||||
error: hasError ? quote.notes : null
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking PDF status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved tjek af PDF status'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3940,6 +3889,178 @@ app.get('/api/customer-projects/:projectId/quotes', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Ordrestyring Integration Endpoints
|
||||
|
||||
// Test Ordrestyring API connection
|
||||
app.get('/api/ordrestyring/test', async (req, res) => {
|
||||
try {
|
||||
if (!ordrestyringService) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'Ordrestyring service ikke initialiseret'
|
||||
});
|
||||
}
|
||||
|
||||
const testResult = await ordrestyringService.testConnection();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Ordrestyring API forbindelse succesfuld',
|
||||
data: testResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ordrestyring test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved test af Ordrestyring API: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Import all cases from Ordrestyring
|
||||
app.post('/api/ordrestyring/import', async (req, res) => {
|
||||
try {
|
||||
if (!ordrestyringService || !databaseService) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'Services ikke initialiseret'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Starting Ordrestyring import...');
|
||||
const importResult = await ordrestyringService.importCasesToDatabase(databaseService);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Import fuldført succesfuldt',
|
||||
data: importResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved import af data: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get import statistics
|
||||
app.get('/api/ordrestyring/statistics', async (req, res) => {
|
||||
try {
|
||||
if (!ordrestyringService || !databaseService) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'Services ikke initialiseret'
|
||||
});
|
||||
}
|
||||
|
||||
const statistics = await ordrestyringService.generateStatistics(databaseService);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Statistics error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved generering af statistikker: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get imported quotes with pagination
|
||||
app.get('/api/ordrestyring/quotes', async (req, res) => {
|
||||
try {
|
||||
if (!databaseService) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'Database service ikke initialiseret'
|
||||
});
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
|
||||
// Build where clause for search
|
||||
let whereClause = "WHERE source = 'ordrestyring'";
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
whereClause += " AND (customer_name LIKE ? OR project_name LIKE ? OR external_case_number LIKE ?)";
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const [countResult] = await databaseService.query(
|
||||
`SELECT COUNT(*) as total FROM imported_quotes ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get quotes
|
||||
const quotes = await databaseService.query(
|
||||
`SELECT * FROM imported_quotes ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
quotes: quotes,
|
||||
pagination: {
|
||||
current_page: page,
|
||||
per_page: limit,
|
||||
total: countResult.total,
|
||||
total_pages: Math.ceil(countResult.total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting imported quotes:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved hentning af importerede tilbud'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get detailed case from Ordrestyring by ID
|
||||
app.get('/api/ordrestyring/case/:caseId', async (req, res) => {
|
||||
try {
|
||||
if (!ordrestyringService) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'Ordrestyring service ikke initialiseret'
|
||||
});
|
||||
}
|
||||
|
||||
const { caseId } = req.params;
|
||||
const caseData = await ordrestyringService.getCaseById(caseId);
|
||||
|
||||
if (!caseData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Case ikke fundet'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: caseData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting case details:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved hentning af case detaljer: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
const startServer = async () => {
|
||||
const backendReady = await initializeBackend();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20250917114535+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917114535+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1339
|
||||
>>
|
||||
stream
|
||||
Gatm;D0+\p&H88.YohF)E@ff6$f=KpjuAO\'n#b4R?!)W;>`mp6&0sIPmJe.iksh<0HNK[I@f8CZmuF7p`duSV#YariVnr>Tb+DX\<[Nm'1DB8?T7&jd\E*EdZB"*I#EYaM#2/E/6^8/Fr5a%DpT#/?$d,!k`u;?N*Ao6;hnJ(8:pooirj8[aj\/:0g*`N&2,$\/sK1V:o,j!7?RsWLft/icSFN)LJLqLP5PIP/&]3>h7m5=gHY1MN\RA2^hhUV5&W^Qf&.+&rL[K]+W/Fch;kL@ju/P$&3fWiDB>N7EpG')cOXK5<Rn.koFRnS1?$%;lQ0`Rqa%SDf@:FgDqUgQD9@040k(;=AY!Q/S?Ol\Nm)V(\=O?%1HjqfLbd.n!jA()EE[uFlcq:g<=0@A_HN0c;'mKj"meXON/X%t!ObRjad"JiR0$a3dKe#qcn^AsYM[&tQfDI^?j2pJc8_2jns_pH*5juZaKsgt(,K0[0*2oRU5uD\]iGc\hn`E./H?;(*lV$A+G&^%!:(Y$?md,\4'YukFcXFeN:XJ<`-oK\HD4`m*B$%uFG9+j4ihK7%Lu!Cp>p?PN?=njF>iq94jBb\ANm9c>_JkYLKm_V7"r`<;D@p`pJU4',1HV&L1CZBe2UB'#cn61#F=.md#D"`:>ia;<181^151^f<hca.<FT03Ao!!VNRD@,koJ(hi?_Sd,uZBo@9bBQDU)0qX9VRkc&eFke`I[Th=/!F*$`sEj''6E>74`3U:&]f"/LJDh'OTAX%Yc=#(kh_3::_DRt,f!)Q/?*O<Em@ZqF1!f+iTgH\akH,8`p,pbmS+30$.[junUhK\f/=o;uFeHllg=cM&84KK<&%lT[,k4[Au(B0bXmaKZkd)idk=EN)B4-l1=-4eTL3.K0A?81her;JV-[*C<#bq1N3PE0ic^#18&.ZMp9),O@nAH3c(+/_c"Rh&@h,J-c]5696tpK6I2S#sOF0BJp_k7C+&@UE7%#nq6-6NpTm<gsj,J2UjdoY_fteLm,&77Lo^+s$&4DfTC[t>_,[(k.)j'MZ9%Df<otc'>-0SI^]kZ86UZ3L@$"E@*Br+ikOFfgUSjK]KGCFX2T5h/&bq^RgG/am.@qa%[RKECt#:B@=%cJi/n[4.<9=GG9QN^'O^PtJ..p^e>N;)j('a)[N!s1LCnEKQSUG]dg@IHR@md>fW]m@du8n.$k7%Fk%:sTV^W+",fg_/rZ4e*G_&<$OETg;3>l'`bpW,YfBB]cj((3.-XV>kER5!jagm(J?>"2YXr3Vg&8q`rlRa_/.L,MIl@+10CqTLq]cm@4UQG*7;HMS2OtcO]GsJn8#JaM[Rf~>endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1246
|
||||
>>
|
||||
stream
|
||||
GatU3gN)%,&:O:Slq:u]N6;dFML)99bGln0m7OIDcKGEe5RJi/\0*&Xs1Sk5V4*?V;>03?O^-odmOldf'6*J1RdZ"Ei,]6tXpGXMdL8\)"HZAePb.hp7WsoVk<t/n&;[+6+\@pVhr/F]\ZPHZ>_08?i\KV=6+:"DZceosDRa"mb$]3a6H=4u=aV&1=Fs2$/en-aUlUUd[((VFlO\6$]Fk$EC%KCr?5npoT%h+08SCor_>E;Qn2IAK/T1I<^)oe;'7'Oq8_![D\;(1ATFL*/7^g5t+QS$jk*J$U;$qNRaW-KV%=E*POFF8bfSKf@-`JM1mrraY>#%B$C!^h1^R^AH0O=IfH_$0>SP8!W1*6-sj<_hX&!popiY_+U_@N@G;%$BTb!Ba].jIKsiX>rdBje?bmbJ/1'O/*Q+%"i#-e4FfZQis:Xm/UEOu?B^U`*uMfgB?(m=$)='IH>Nnd;96?AU/8ZaF?+@\4fR.5#K61hSJPYuc+-2U0dH#I-79#I/UVKdo`u_$Gl>,\B'eAt(2G0b0R7fI5![TR+^%)=Kj@_AtkV*RiR"Tc^`ESNAJaa;ZR$NdZSNZ*si<`XU;=i!KGR.8"El@RHtGgu&nJ7QibNh`M7t):_h&W't-]_Y]a#9gMfPZ6e$D(Ok`%[W!&_4n1PM:jY4CUFf`,q8!2CMTciW>;mnuN+RU"#cgI%3^$Aqp-E0FlV(G8fE[on%-,OajWE_t1kXO;bX]#:ad5j8=U65G.,ZVX2:f;#C+qJ7#:XJ#Q7n3K4`CMl4h,]>A\>Hd`$OWE7_.1(11CJr2rqD$qqC`UUqf'`qH"M^<Ef"2!)pkJ`19]ulL\OcZ3LChH5D.g-32f7[_;2RTVVATctr@7%"*.K&qrVaHhoI2"&AGc1oelO?!X.p@Y^!DSt]qq0EL'bX3:%3#;r#>#g-O621NsWS2c"kJo#[c;@P-"CL6[Aj10(295RIj-q:u*hO%B7'jR)Ko[7\'$B"6K]93[f4MaZe?#`-rb_R)&"&@=.Y6t090KnL7$iHb:*Jr^;B;ulep[@L]#9HWsDo3NZ<6-5O?)E)BQS3X0X]V]n_trHJ4eBg6Yi#5]`a(TXkN?L@A6`U2+<YZ:NJWqh?,2:4CYM$?Z)Zd)b!Ts=(\%(GYd[Q8erasn6^^5m>t^Em$Xd`uBSH&2P'jm/@qUhH)FpI6K;1qUdlPBlNrO'3/nJPU[@oRZWRe#WH/_7=l2n)s;-*[KT)WW8E:tqfO\s4~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 11
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000536 00000 n
|
||||
0000000740 00000 n
|
||||
0000000808 00000 n
|
||||
0000001091 00000 n
|
||||
0000001156 00000 n
|
||||
0000002586 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<360299b949a2a7c0b219585cf228e12d><360299b949a2a7c0b219585cf228e12d>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 7 0 R
|
||||
/Root 6 0 R
|
||||
/Size 11
|
||||
>>
|
||||
startxref
|
||||
3924
|
||||
%%EOF
|
||||
Reference in New Issue
Block a user