Files
tilbudgivern/backend/src/services/databaseService.js
Alex 9469a896d3 Merge pull request #1 from alexpolo1/copilot/identify-slow-code-improvements
Optimize database operations and async workflows for 5-20x performance gains
2026-01-25 12:01:05 +01:00

1416 lines
48 KiB
JavaScript

const mysql = require('mysql2/promise');
const logger = require('../utils/logger');
class DatabaseService {
constructor() {
this.pool = null;
}
async initialize() {
try {
// SECURITY: Require database password in environment variables
if (!process.env.DB_PASSWORD) {
throw new Error('DB_PASSWORD must be set in environment variables');
}
this.pool = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PORT) || 3306,
database: process.env.DB_NAME || 'tilbudgivern',
user: process.env.DB_USER || 'tilbudgivern_service',
password: process.env.DB_PASSWORD, // No fallback - must be configured
waitForConnections: true,
connectionLimit: 20,
queueLimit: 0
});
// Test connection
const connection = await this.pool.getConnection();
await connection.query('SELECT NOW()');
connection.release();
await this.ensureSystemSettingsTable();
await this.syncSupportSettingsFromEnv();
logger.info('Database connection established successfully');
} catch (error) {
logger.error('Failed to initialize database connection:', error);
throw error;
}
}
async ensureSystemSettingsTable() {
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS system_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(191) NOT NULL UNIQUE,
setting_value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
}
async upsertSystemSetting(settingKey, settingValue) {
await this.pool.execute(
`
INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)
`,
[settingKey, settingValue]
);
}
async syncSupportSettingsFromEnv() {
const envSettings = [
{ key: 'osticket_api_key', value: process.env.OSTICKET_API_KEY },
{ key: 'osticket_api_url', value: process.env.OSTICKET_API_URL },
{ key: 'osticket_topic_id', value: process.env.OSTICKET_TOPIC_ID },
{ key: 'osticket_priority_id', value: process.env.OSTICKET_PRIORITY_ID }
];
const entries = envSettings
.map(({ key, value }) => ({
key,
value: typeof value === 'string' ? value.trim() : ''
}))
.filter(({ value }) => value);
if (entries.length === 0) {
return;
}
await this.ensureSystemSettingsTable();
for (const entry of entries) {
await this.upsertSystemSetting(entry.key, entry.value);
}
logger.info(`Synced ${entries.length} support setting(s) from environment`);
}
async query(sql, params = [], options = {}) {
try {
const [rows] = await this.pool.execute(sql, params);
return rows;
} catch (error) {
// Only log errors if not silenced (for expected missing tables/columns)
if (!options.silenceErrors) {
logger.error('Database query error:', { sql, params, error: error.message });
}
throw error;
}
}
async getPriceData(category, subcategory = null) {
try {
let query = 'SELECT * FROM prices WHERE category = ?';
let params = [category];
if (subcategory) {
query += ' AND subcategory = ?';
params.push(subcategory);
}
query += ' ORDER BY updated_at DESC';
const [rows] = await this.pool.execute(query, params);
return rows;
} catch (error) {
logger.error('Error fetching price data:', error);
throw error;
}
}
// Material and supplier methods
async saveSupplier(supplierData) {
try {
const query = `
INSERT INTO suppliers (name, contact_info, notes)
VALUES (?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
supplierData.name,
JSON.stringify(supplierData.contactInfo || {}),
supplierData.notes || null
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving supplier:', error);
throw error;
}
}
async saveMaterial(materialData) {
try {
const query = `
INSERT INTO materials (supplier_id, sku, name, description, unit, package_size, category, subcategory)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
materialData.supplierId || null,
materialData.sku || null,
materialData.name,
materialData.description || null,
materialData.unit,
materialData.packageSize || null,
materialData.category || null,
materialData.subcategory || null
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving material:', error);
throw error;
}
}
async saveMaterialPrice(priceData) {
try {
// Deactivate previous prices for this material if setting new active price
if (priceData.isActive) {
await this.pool.execute(
'UPDATE material_prices SET is_active = FALSE WHERE material_id = ? AND is_active = TRUE',
[priceData.materialId]
);
}
const query = `
INSERT INTO material_prices (
material_id, price, currency, valid_from, valid_to,
confidence_score, source_document, parse_log, is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
priceData.materialId,
priceData.price,
priceData.currency || 'DKK',
priceData.validFrom,
priceData.validTo || null,
priceData.confidenceScore || 1.00,
priceData.sourceDocument || null,
priceData.parseLog || null,
priceData.isActive !== false
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving material price:', error);
throw error;
}
}
async saveLaborTask(laborData) {
try {
const query = `
INSERT INTO labor_tasks (role, task_description, unit, category, subcategory)
VALUES (?, ?, ?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
laborData.role,
laborData.taskDescription,
laborData.unit,
laborData.category || null,
laborData.subcategory || null
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving labor task:', error);
throw error;
}
}
async saveLaborPrice(priceData) {
try {
// Deactivate previous prices for this labor task if setting new active price
if (priceData.isActive) {
await this.pool.execute(
'UPDATE labor_prices SET is_active = FALSE WHERE labor_task_id = ? AND is_active = TRUE',
[priceData.laborTaskId]
);
}
const query = `
INSERT INTO labor_prices (
labor_task_id, rate_per_hour, currency, valid_from, valid_to,
confidence_score, source_document, parse_log, is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
priceData.laborTaskId,
priceData.ratePerHour,
priceData.currency || 'DKK',
priceData.validFrom,
priceData.validTo || null,
priceData.confidenceScore || 1.00,
priceData.sourceDocument || null,
priceData.parseLog || null,
priceData.isActive !== false
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving labor price:', error);
throw error;
}
}
async saveDocumentUpload(documentData) {
try {
const query = `
INSERT INTO document_uploads (
filename, original_name, file_hash, file_size, mime_type, document_type
) VALUES (?, ?, ?, ?, ?, ?)
`;
const [result] = await this.pool.execute(query, [
documentData.filename,
documentData.originalName,
documentData.fileHash,
documentData.fileSize,
documentData.mimeType,
documentData.documentType
]);
return { id: result.insertId };
} catch (error) {
logger.error('Error saving document upload:', error);
throw error;
}
}
async updateDocumentParseStatus(documentId, status, results = null) {
try {
const query = `
UPDATE document_uploads
SET parse_status = ?, parse_results = ?
WHERE id = ?
`;
await this.pool.execute(query, [status, JSON.stringify(results), documentId]);
} catch (error) {
logger.error('Error updating document parse status:', error);
throw error;
}
}
// Historical data analysis methods
async getHistoricalPrices(category, subcategory = null, limit = 10) {
try {
let query = `
SELECT m.name, m.unit, mp.price, mp.valid_from, s.name as supplier_name
FROM materials m
JOIN material_prices mp ON m.id = mp.material_id
LEFT JOIN suppliers s ON m.supplier_id = s.id
WHERE m.category = ?
`;
let params = [category];
if (subcategory) {
query += ' AND m.subcategory = ?';
params.push(subcategory);
}
query += ' AND mp.is_active = TRUE ORDER BY mp.valid_from DESC LIMIT ?';
params.push(limit);
const [rows] = await this.pool.execute(query, params);
return rows;
} catch (error) {
logger.error('Error fetching historical prices:', error);
throw error;
}
}
async getHistoricalLaborRates(role, limit = 10) {
try {
let query = `
SELECT lt.task_description, lt.unit, lp.rate_per_hour, lp.valid_from
FROM labor_tasks lt
JOIN labor_prices lp ON lt.id = lp.labor_task_id
WHERE lt.role = ?
`;
let params = [role];
query += ' AND lp.is_active = TRUE ORDER BY lp.valid_from DESC LIMIT ?';
params.push(limit);
const [rows] = await this.pool.execute(query, params);
return rows;
} catch (error) {
logger.error('Error fetching historical labor rates:', error);
throw error;
}
}
async getPriceStatistics(category, subcategory = null) {
try {
let query = `
SELECT
COUNT(*) as total_quotes,
AVG(mp.price) as avg_price,
MIN(mp.price) as min_price,
MAX(mp.price) as max_price,
STDDEV(mp.price) as price_stddev
FROM materials m
JOIN material_prices mp ON m.id = mp.material_id
WHERE m.category = ?
`;
let params = [category];
if (subcategory) {
query += ' AND m.subcategory = ?';
params.push(subcategory);
}
query += ' AND mp.is_active = TRUE AND mp.valid_from >= DATE_SUB(NOW(), INTERVAL 2 YEAR)';
const [rows] = await this.pool.execute(query, params);
return rows[0] || null;
} catch (error) {
logger.error('Error fetching price statistics:', error);
throw error;
}
}
async saveQuote(quoteData) {
try {
const query = `
INSERT INTO quotes (
customer_email,
project_description,
project_area,
project_type,
generated_quote,
metadata
) VALUES (?, ?, ?, ?, ?, ?)
`;
const values = [
quoteData.customerEmail || null,
quoteData.description || null,
quoteData.area || null,
quoteData.projectType || null,
quoteData.quote || null,
JSON.stringify(quoteData.metadata || {})
];
const [result] = await this.pool.execute(query, values);
logger.info('Quote saved to database', { quoteId: result.insertId });
return { id: result.insertId, created_at: new Date() };
} catch (error) {
logger.error('Error saving quote:', error);
throw error;
}
}
async saveFeedback(quoteId, feedback, adjustedQuote = null) {
try {
const query = `
INSERT INTO quote_feedback (quote_id, feedback, adjusted_quote)
VALUES (?, ?, ?)
`;
const [result] = await this.pool.execute(query, [quoteId, feedback, adjustedQuote]);
logger.info('Feedback saved', { feedbackId: result.insertId });
return { id: result.insertId };
} catch (error) {
logger.error('Error saving feedback:', error);
throw error;
}
}
async getQuoteById(id) {
try {
const query = 'SELECT * FROM quotes WHERE id = ?';
const [rows] = await this.pool.execute(query, [id]);
if (rows.length === 0) {
return null;
}
return rows[0];
} catch (error) {
logger.error('Error fetching quote by ID:', error);
throw error;
}
}
async createTables() {
try {
// Create suppliers table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS suppliers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
contact_info JSON,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Create materials table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS materials (
id INT AUTO_INCREMENT PRIMARY KEY,
supplier_id INT,
sku VARCHAR(100),
name VARCHAR(255) NOT NULL,
description TEXT,
unit VARCHAR(50) NOT NULL,
package_size DECIMAL(10,3),
category VARCHAR(100),
subcategory VARCHAR(100),
brand VARCHAR(100),
specifications JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
INDEX idx_category (category),
INDEX idx_subcategory (subcategory),
INDEX idx_sku (sku)
)
`);
// Table for storing OCR documents and metadata
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS ocr_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
document_type ENUM('quote', 'invoice', 'price_list', 'other') DEFAULT 'quote',
supplier_name VARCHAR(255),
document_date DATE,
total_amount DECIMAL(12,2),
currency VARCHAR(3) DEFAULT 'DKK',
ocr_confidence DECIMAL(3,2),
raw_text TEXT,
processing_status ENUM('pending', 'processed', 'failed') DEFAULT 'processed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_document_type (document_type),
INDEX idx_supplier (supplier_name),
INDEX idx_created_date (created_at)
)
`);
// Table for storing individual parsed materials from OCR
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS ocr_materials (
id INT AUTO_INCREMENT PRIMARY KEY,
ocr_document_id INT NOT NULL,
sku VARCHAR(100),
name VARCHAR(255) NOT NULL,
description TEXT,
quantity DECIMAL(10,3) NOT NULL,
unit VARCHAR(50) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'DKK',
confidence DECIMAL(3,2),
material_category VARCHAR(100),
is_verified BOOLEAN DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ocr_document_id) REFERENCES ocr_documents(id) ON DELETE CASCADE,
INDEX idx_sku (sku),
INDEX idx_category (material_category),
INDEX idx_name (name),
INDEX idx_unit_price (unit_price)
)
`);
// Table for storing project quotes based on OCR data
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
ocr_document_id INT,
project_type VARCHAR(100) NOT NULL,
project_area DECIMAL(10,2),
customer_name VARCHAR(255),
customer_address TEXT,
material_cost DECIMAL(12,2) NOT NULL,
labor_cost DECIMAL(12,2) NOT NULL,
overhead_cost DECIMAL(12,2) NOT NULL,
total_excl_vat DECIMAL(12,2) NOT NULL,
vat_amount DECIMAL(12,2) NOT NULL,
total_incl_vat DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'DKK',
labor_rate DECIMAL(10,2),
difficulty_multiplier DECIMAL(3,2) DEFAULT 1.0,
overhead_percentage DECIMAL(5,2),
profit_percentage DECIMAL(5,2),
quote_status ENUM('draft', 'sent', 'accepted', 'declined') DEFAULT 'draft',
valid_until DATE,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (ocr_document_id) REFERENCES ocr_documents(id),
INDEX idx_project_type (project_type),
INDEX idx_status (quote_status),
INDEX idx_created_date (created_at)
)
`);
// Create material prices table (with versioning)
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS material_prices (
id INT AUTO_INCREMENT PRIMARY KEY,
material_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'DKK',
valid_from DATE NOT NULL,
valid_to DATE,
confidence_score DECIMAL(3,2) DEFAULT 1.00,
source_document VARCHAR(255),
parse_log TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (material_id) REFERENCES materials(id)
)
`);
// Create labor tasks table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS labor_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
role VARCHAR(100) NOT NULL,
task_description VARCHAR(255) NOT NULL,
unit VARCHAR(50) NOT NULL,
category VARCHAR(100),
subcategory VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Create labor prices table (with versioning)
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS labor_prices (
id INT AUTO_INCREMENT PRIMARY KEY,
labor_task_id INT NOT NULL,
rate_per_hour DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'DKK',
valid_from DATE NOT NULL,
valid_to DATE,
confidence_score DECIMAL(3,2) DEFAULT 1.00,
source_document VARCHAR(255),
parse_log TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (labor_task_id) REFERENCES labor_tasks(id)
)
`);
// Create document uploads table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS document_uploads (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_hash VARCHAR(64) NOT NULL,
file_size INT NOT NULL,
mime_type VARCHAR(100),
document_type ENUM('material', 'labor') NOT NULL,
parse_status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
parse_results JSON,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create legacy prices table (keep existing for backward compatibility)
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS prices (
id INT AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
description TEXT,
unit VARCHAR(20) NOT NULL,
price_per_unit DECIMAL(10,2) NOT NULL,
labor_hours_per_unit DECIMAL(6,2),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Create quotes table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_email VARCHAR(255),
project_description TEXT NOT NULL,
project_area DECIMAL(10,2),
project_type VARCHAR(100),
generated_quote TEXT NOT NULL,
total_price DECIMAL(12,2),
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create feedback table
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS quote_feedback (
id INT AUTO_INCREMENT PRIMARY KEY,
quote_id INT,
feedback TEXT NOT NULL,
adjusted_quote TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (quote_id) REFERENCES quotes(id)
)
`);
// Create customer projects system tables
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS customer_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_description TEXT,
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (project_status),
INDEX idx_customer (customer_name),
INDEX idx_created (created_at)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS roof_geometry (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
roof_type ENUM('fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL,
total_area DECIMAL(10,2) NOT NULL COMMENT 'Samlet areal i m²',
roof_pitch DECIMAL(5,2) COMMENT 'Taghældning i grader',
roof_height DECIMAL(8,2) COMMENT 'Taghøjde i meter',
complexity_factor DECIMAL(3,2) DEFAULT 1.0 COMMENT 'Kompleksitetsfaktor 1.0-2.0',
length_main DECIMAL(8,2) COMMENT 'Hovedlængde i meter',
width_main DECIMAL(8,2) COMMENT 'Hovedbredde i meter',
has_dormers BOOLEAN DEFAULT FALSE COMMENT 'Har kviste',
has_chimneys BOOLEAN DEFAULT FALSE COMMENT 'Har skorstene',
has_skylights BOOLEAN DEFAULT FALSE COMMENT 'Har ovenlys',
access_difficulty ENUM('let', 'medium', 'svær') DEFAULT 'medium',
estimated_work_hours DECIMAL(8,2) COMMENT 'Estimerede arbejdstimer',
estimated_carpenters INT COMMENT 'Anbefalede antal tømrere',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_roof_type (roof_type)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_labor (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
carpenter_count INT NOT NULL DEFAULT 1,
estimated_hours_per_carpenter DECIMAL(8,2) NOT NULL,
total_work_hours DECIMAL(8,2) NOT NULL,
hourly_rate DECIMAL(8,2) NOT NULL DEFAULT 580.00 COMMENT 'Fast pris 580 kr/time',
total_labor_cost DECIMAL(12,2) NOT NULL,
work_breakdown JSON COMMENT 'Detaljeret opdeling af arbejdsopgaver',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_materials (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
material_name VARCHAR(255) NOT NULL,
material_category VARCHAR(100),
quantity DECIMAL(10,3) NOT NULL,
unit VARCHAR(50) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(12,2) NOT NULL,
supplier VARCHAR(255),
material_source ENUM('manual', 'database', 'bygma_api') DEFAULT 'manual',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_category (material_category)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS project_calculations (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
total_area DECIMAL(10,2),
complexity_factor DECIMAL(3,2),
total_work_hours DECIMAL(8,2),
carpenter_count INT,
total_labor_cost DECIMAL(12,2),
total_material_cost DECIMAL(12,2),
material_count INT,
subtotal DECIMAL(12,2),
overhead_percentage DECIMAL(5,2) DEFAULT 15.00,
overhead_amount DECIMAL(12,2),
profit_percentage DECIMAL(5,2) DEFAULT 20.00,
profit_amount DECIMAL(12,2),
total_excl_vat DECIMAL(12,2),
vat_percentage DECIMAL(5,2) DEFAULT 25.00,
vat_amount DECIMAL(12,2),
total_incl_vat DECIMAL(12,2),
calculation_data JSON COMMENT 'Detaljerede beregningsdata',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
INDEX idx_project (project_id)
)
`);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS generated_quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
calculation_id INT NOT NULL,
quote_text TEXT NOT NULL COMMENT 'AI-genereret tilbudstekst',
quote_format ENUM('text', 'html', 'pdf') DEFAULT 'html',
ai_tokens_used INT,
ai_cost DECIMAL(8,4),
ai_model VARCHAR(50),
quote_status ENUM('draft', 'approved', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
sent_at TIMESTAMP NULL,
valid_until DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE,
FOREIGN KEY (calculation_id) REFERENCES project_calculations(id) ON DELETE CASCADE,
INDEX idx_project (project_id),
INDEX idx_status (quote_status)
)
`);
logger.info('Database tables created/verified successfully');
} catch (error) {
logger.error('Error creating database tables:', error);
throw error;
}
}
async seedPriceData() {
try {
// Check if data already exists
const [existingData] = await this.pool.execute('SELECT COUNT(*) as count FROM prices');
if (parseInt(existingData[0].count) > 0) {
logger.info('Price data already exists, skipping seed');
return;
}
const priceData = [
// Gulvarbejde
{ category: 'gulv', subcategory: 'klik', description: 'Klikgulv lægning', unit: 'm²', price: 110, labor: 0.5 },
{ category: 'gulv', subcategory: 'parket', description: 'Parketgulv lægning', unit: 'm²', price: 150, labor: 0.7 },
{ category: 'gulv', subcategory: 'planker', description: 'Gulvplanker lægning', unit: 'm²', price: 130, labor: 0.6 },
// Tagarbejde
{ category: 'tag', subcategory: 'tegl', description: 'Tegltag inkl. materialer', unit: 'm²', price: 1300, labor: 2.0 },
{ category: 'tag', subcategory: 'reparation', description: 'Tag reparation', unit: 'time', price: 500, labor: 1.0 },
// Tømrerarbejde
{ category: 'tømrer', subcategory: 'døre', description: 'Dør montering', unit: 'stk', price: 2500, labor: 3.0 },
{ category: 'tømrer', subcategory: 'vinduer', description: 'Vindue montering', unit: 'stk', price: 3500, labor: 4.0 },
{ category: 'tømrer', subcategory: 'terrasse', description: 'Terrasse bygning', unit: 'm²', price: 1000, labor: 1.5 },
// Grundlæggende satser
{ category: 'løn', subcategory: 'tømrer', description: 'Tømrer timeløn', unit: 'time', price: 500, labor: 1.0 },
{ category: 'overhead', subcategory: 'administration', description: 'Administration og overhead', unit: '%', price: 10, labor: 0.0 }
];
// PERFORMANCE: Batch insert all price data in a single query
const values = priceData.map(item => [
item.category, item.subcategory, item.description,
item.unit, item.price, item.labor
]);
const placeholders = values.map(() => '(?, ?, ?, ?, ?, ?)').join(', ');
const flatValues = values.flat();
await this.pool.execute(`
INSERT INTO prices (category, subcategory, description, unit, price_per_unit, labor_hours_per_unit)
VALUES ${placeholders}
`, flatValues);
logger.info('Price data seeded successfully');
} catch (error) {
logger.error('Error seeding price data:', error);
throw error;
}
}
// OCR Document management methods
async saveOcrDocument(documentData) {
try {
const {
filename,
documentType = 'quote',
supplierName,
documentDate,
totalAmount,
currency = 'DKK',
ocrConfidence,
rawText,
processingStatus = 'processed'
} = documentData;
const [result] = await this.pool.execute(
`INSERT INTO ocr_documents
(filename, document_type, supplier_name, document_date, total_amount,
currency, ocr_confidence, raw_text, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[filename, documentType, supplierName, documentDate, totalAmount,
currency, ocrConfidence, rawText, processingStatus]
);
logger.info(`OCR document saved with ID: ${result.insertId}`);
return result.insertId;
} catch (error) {
logger.error('Error saving OCR document:', error);
throw error;
}
}
async saveOcrMaterials(ocrDocumentId, materials) {
try {
const connection = await this.pool.getConnection();
await connection.beginTransaction();
try {
// PERFORMANCE: Batch insert all materials in a single query
if (materials.length > 0) {
const values = materials.map(material => {
const {
sku,
name,
description,
quantity,
unit,
unitPrice,
totalPrice,
currency = 'DKK',
confidence,
materialCategory
} = material;
return [ocrDocumentId, sku, name, description, quantity, unit,
unitPrice, totalPrice, currency, confidence, materialCategory];
});
const placeholders = values.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
const flatValues = values.flat();
await connection.execute(
`INSERT INTO ocr_materials
(ocr_document_id, sku, name, description, quantity, unit,
unit_price, total_price, currency, confidence, material_category)
VALUES ${placeholders}`,
flatValues
);
}
await connection.commit();
logger.info(`Saved ${materials.length} OCR materials for document ${ocrDocumentId}`);
return materials.length;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
} catch (error) {
logger.error('Error saving OCR materials:', error);
throw error;
}
}
async saveProjectQuote(quoteData) {
try {
const {
ocrDocumentId,
projectType,
projectArea,
customerName,
customerAddress,
materialCost,
laborCost,
overheadCost,
totalExclVat,
vatAmount,
totalInclVat,
currency = 'DKK',
laborRate,
difficultyMultiplier = 1.0,
overheadPercentage,
profitPercentage,
quoteStatus = 'draft',
validUntil,
notes
} = quoteData;
const [result] = await this.pool.execute(
`INSERT INTO project_quotes
(ocr_document_id, project_type, project_area, customer_name, customer_address,
material_cost, labor_cost, overhead_cost, total_excl_vat, vat_amount,
total_incl_vat, currency, labor_rate, difficulty_multiplier,
overhead_percentage, profit_percentage, quote_status, valid_until, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ocrDocumentId, projectType, projectArea, customerName, customerAddress,
materialCost, laborCost, overheadCost, totalExclVat, vatAmount,
totalInclVat, currency, laborRate, difficultyMultiplier,
overheadPercentage, profitPercentage, quoteStatus, validUntil, notes]
);
logger.info(`Project quote saved with ID: ${result.insertId}`);
return result.insertId;
} catch (error) {
logger.error('Error saving project quote:', error);
throw error;
}
}
// Get historical OCR materials for pricing suggestions
async getHistoricalMaterialPrices(materialName, limit = 10) {
try {
const [rows] = await this.pool.execute(
`SELECT om.*, od.supplier_name, od.document_date, od.document_type
FROM ocr_materials om
JOIN ocr_documents od ON om.ocr_document_id = od.id
WHERE om.name LIKE ? OR om.sku LIKE ?
ORDER BY od.document_date DESC, om.unit_price ASC
LIMIT ?`,
[`%${materialName}%`, `%${materialName}%`, limit]
);
return rows;
} catch (error) {
logger.error('Error getting historical material prices:', error);
throw error;
}
}
async getOcrDocumentWithMaterials(documentId) {
try {
// Get document info
const [docRows] = await this.pool.execute(
'SELECT * FROM ocr_documents WHERE id = ?',
[documentId]
);
if (docRows.length === 0) {
return null;
}
// Get materials
const [materialRows] = await this.pool.execute(
'SELECT * FROM ocr_materials WHERE ocr_document_id = ? ORDER BY name',
[documentId]
);
return {
document: docRows[0],
materials: materialRows
};
} catch (error) {
logger.error('Error getting OCR document with materials:', error);
throw error;
}
}
// Search materials across all OCR documents
async searchOcrMaterials(searchTerm, projectType = null, limit = 20) {
try {
let query = `
SELECT om.*, od.supplier_name, od.document_date, od.document_type,
COUNT(*) OVER() as total_matches
FROM ocr_materials om
JOIN ocr_documents od ON om.ocr_document_id = od.id
WHERE (om.name LIKE ? OR om.sku LIKE ? OR om.description LIKE ?)
`;
const params = [`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`];
if (projectType) {
query += ` AND om.material_category = ?`;
params.push(projectType);
}
query += ` ORDER BY od.document_date DESC, om.unit_price ASC LIMIT ?`;
params.push(limit);
const [rows] = await this.pool.execute(query, params);
return rows;
} catch (error) {
logger.error('Error searching OCR materials:', error);
throw error;
}
}
// Get historical labor rates for project type
async getHistoricalLaborRates(projectType, limit = 10) {
try {
const [rows] = await this.pool.execute(
`SELECT pq.*, od.supplier_name, od.document_date
FROM project_quotes pq
JOIN ocr_documents od ON pq.ocr_document_id = od.id
WHERE pq.project_type = ? AND pq.labor_rate IS NOT NULL
ORDER BY od.document_date DESC
LIMIT ?`,
[projectType, limit]
);
return rows;
} catch (error) {
logger.error('Error getting historical labor rates:', error);
throw error;
}
}
// Get historical material prices with better matching
async getHistoricalMaterialPricesAdvanced(materialName, category = null, limit = 10) {
try {
let query = `
SELECT om.*, od.supplier_name, od.document_date, od.document_type,
CASE
WHEN om.name = ? THEN 100
WHEN om.name LIKE ? THEN 80
WHEN om.sku LIKE ? THEN 70
WHEN om.description LIKE ? THEN 60
ELSE 50
END as match_score
FROM ocr_materials om
JOIN ocr_documents od ON om.ocr_document_id = od.id
WHERE (om.name LIKE ? OR om.sku LIKE ? OR om.description LIKE ?)
`;
const exactMatch = materialName;
const likeMatch = `%${materialName}%`;
const params = [exactMatch, likeMatch, likeMatch, likeMatch, likeMatch, likeMatch, likeMatch];
if (category) {
query += ` AND om.material_category = ?`;
params.push(category);
}
query += ` ORDER BY match_score DESC, od.document_date DESC LIMIT ?`;
params.push(limit);
const [rows] = await this.pool.execute(query, params);
return rows;
} catch (error) {
logger.error('Error getting advanced historical material prices:', error);
throw error;
}
}
// Get project statistics for intelligent suggestions
async getProjectStatistics(projectType) {
try {
const [rows] = await this.pool.execute(
`SELECT
COUNT(*) as total_projects,
AVG(labor_rate) as avg_labor_rate,
MIN(labor_rate) as min_labor_rate,
MAX(labor_rate) as max_labor_rate,
AVG(overhead_percentage) as avg_overhead,
AVG(profit_percentage) as avg_profit,
AVG(difficulty_multiplier) as avg_difficulty
FROM project_quotes
WHERE project_type = ? AND labor_rate IS NOT NULL`,
[projectType]
);
return rows[0];
} catch (error) {
logger.error('Error getting project statistics:', error);
throw error;
}
}
// Category-specific pricing methods
async getLaborPricesByCategory(category, limit = 20) {
try {
const [rows] = await this.pool.execute(
`SELECT
pq.id,
pq.project_type,
pq.notes as description,
pq.labor_rate,
pq.project_area as area,
ROUND(pq.labor_cost / NULLIF(pq.labor_rate, 0), 1) as total_hours,
pq.difficulty_multiplier,
pq.created_at,
CASE
WHEN pq.difficulty_multiplier > 1.3 THEN 'høj'
WHEN pq.difficulty_multiplier > 1.1 THEN 'medium'
ELSE 'normal'
END as difficulty_level
FROM project_quotes pq
WHERE LOWER(pq.project_type) LIKE LOWER(?)
AND pq.labor_rate IS NOT NULL
ORDER BY pq.created_at DESC
LIMIT ?`,
[`%${category}%`, parseInt(limit)]
);
return rows;
} catch (error) {
logger.error('Error getting labor prices by category:', error);
throw error;
}
}
async getMaterialPricesByCategory(category, limit = 50) {
try {
const [rows] = await this.pool.execute(
`SELECT
om.id,
om.name,
om.material_category as category,
om.description as subcategory,
om.unit_price as price,
om.unit,
od.supplier_name,
om.sku,
om.confidence,
om.created_at,
od.document_date
FROM ocr_materials om
LEFT JOIN ocr_documents od ON om.ocr_document_id = od.id
WHERE LOWER(om.material_category) = LOWER(?)
AND om.unit_price > 0
ORDER BY om.created_at DESC
LIMIT ?`,
[category, parseInt(limit)]
);
return rows;
} catch (error) {
logger.error('Error getting material prices by category:', error);
throw error;
}
}
async addLaborPriceEntry(laborData) {
try {
const [result] = await this.pool.execute(
`INSERT INTO project_quotes (
project_type, project_area, labor_rate, labor_cost, material_cost,
overhead_cost, total_excl_vat, vat_amount, total_incl_vat,
difficulty_multiplier, notes, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
laborData.projectType,
laborData.area,
laborData.hourlyRate,
(laborData.totalHours || 0) * laborData.hourlyRate, // labor_cost
0, // material_cost (default 0 for manual entries)
0, // overhead_cost (default 0)
(laborData.totalHours || 0) * laborData.hourlyRate, // total_excl_vat
0, // vat_amount (default 0)
(laborData.totalHours || 0) * laborData.hourlyRate, // total_incl_vat
laborData.difficulty === 'høj' ? 1.5 : laborData.difficulty === 'medium' ? 1.2 : 1.0,
`${laborData.description || ''}\nTimer: ${laborData.totalHours || 'N/A'}`,
laborData.entryDate || new Date()
]
);
return {
id: result.insertId,
...laborData,
difficulty_multiplier: laborData.difficulty === 'høj' ? 1.5 : laborData.difficulty === 'medium' ? 1.2 : 1.0
};
} catch (error) {
logger.error('Error adding labor price entry:', error);
throw error;
}
}
async addMaterialPriceEntry(materialData) {
try {
// First create a manual entry document record
const [docResult] = await this.pool.execute(
`INSERT INTO ocr_documents (
filename, supplier_name, document_date, document_type,
processing_status, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
[
'Manual Entry',
materialData.supplierName || 'Manual',
new Date(),
'price_list',
'processed',
materialData.entryDate || new Date()
]
);
const documentId = docResult.insertId;
// Then add the material with reference to the document
const [materialResult] = await this.pool.execute(
`INSERT INTO ocr_materials (
ocr_document_id, name, material_category, description,
quantity, unit_price, total_price, unit, sku, confidence, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
documentId,
materialData.name,
materialData.category,
materialData.description || materialData.notes,
1, // Default quantity for manual entries
materialData.price,
materialData.price, // total_price = unit_price for manual entries
materialData.unit,
materialData.sku,
1.0, // Manual entries have 100% confidence
materialData.entryDate || new Date()
]
);
return {
id: materialResult.insertId,
documentId: documentId,
...materialData,
confidence: 1.0
};
} catch (error) {
logger.error('Error adding material price entry:', error);
throw error;
}
}
async getAllCategories() {
try {
// Get material categories from case_materials instead of ocr_materials
const [materialCategories] = await this.pool.execute(
`SELECT DISTINCT
CASE
WHEN product_text LIKE '%tag%' OR product_text LIKE '%Tag%' THEN 'Tag/Tagmaterialer'
WHEN product_text LIKE '%gulv%' OR product_text LIKE '%Gulv%' THEN 'Gulv'
WHEN product_text LIKE '%vand%' OR product_text LIKE '%Vand%' OR product_text LIKE '%VVS%' THEN 'VVS'
WHEN product_text LIKE '%el%' OR product_text LIKE '%El%' OR product_text LIKE '%elektrik%' THEN 'Elektrik'
WHEN product_text LIKE '%træ%' OR product_text LIKE '%Træ%' OR product_text LIKE '%timber%' THEN 'Træ'
WHEN product_text LIKE '%mur%' OR product_text LIKE '%Mur%' OR product_text LIKE '%sten%' THEN 'Murværk'
WHEN product_text LIKE '%isolering%' OR product_text LIKE '%Isolering%' THEN 'Isolering'
WHEN product_text LIKE '%vinduer%' OR product_text LIKE '%døre%' THEN 'Vinduer/Døre'
ELSE 'Diverse'
END as category,
COUNT(*) as count
FROM case_materials
WHERE product_text IS NOT NULL AND product_text != ''
GROUP BY category
ORDER BY count DESC, category ASC`
);
// Get project types from cases table instead of project_quotes
const [projectTypes] = await this.pool.execute(
`SELECT DISTINCT case_type as project_type, COUNT(*) as count
FROM cases
WHERE case_type IS NOT NULL AND case_type != ''
GROUP BY case_type
ORDER BY count DESC, case_type ASC
LIMIT 20`
);
return {
materialCategories: materialCategories,
projectTypes: projectTypes
};
} catch (error) {
logger.error('Error getting categories:', error);
throw error;
}
}
async saveCarpenterCalculation(calculationData) {
try {
const query = `
INSERT INTO project_quotes (
project_type, project_area, notes, labor_rate,
labor_cost, material_cost, overhead_cost, total_excl_vat,
vat_amount, total_incl_vat, difficulty_multiplier, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const estimatedCost = calculationData.total_hours * (calculationData.cost_estimate_range?.hourly_rate || 475);
const difficultyMultiplier = calculationData.difficulty_level === 'høj' ? 1.5 :
calculationData.difficulty_level === 'medium' ? 1.2 : 1.0;
const metadata = {
ai_calculation: calculationData,
carpenters_recommended: calculationData.recommended_carpenters,
hours_per_carpenter: calculationData.hours_per_carpenter,
time_breakdown: calculationData.time_breakdown,
confidence: calculationData.confidence
};
const [result] = await this.pool.execute(query, [
calculationData.project_input?.type || 'Beregning',
calculationData.project_input?.area || null,
`AI Beregning: ${calculationData.project_input?.description || ''}\nAnbefalet tømrere: ${calculationData.recommended_carpenters}\nEffektivitetsnote: ${calculationData.efficiency_note}`,
calculationData.cost_estimate_range?.hourly_rate || 475,
estimatedCost,
0, // material_cost
0, // overhead_cost
estimatedCost, // total_excl_vat
estimatedCost * 0.25, // vat_amount (25%)
estimatedCost * 1.25, // total_incl_vat
difficultyMultiplier,
JSON.stringify(metadata)
]);
logger.info('Carpenter calculation saved to database', {
calculationId: result.insertId,
projectType: calculationData.project_input?.type,
totalHours: calculationData.total_hours,
carpenters: calculationData.recommended_carpenters
});
return { id: result.insertId, created_at: new Date() };
} catch (error) {
logger.error('Error saving carpenter calculation:', error);
throw error;
}
}
async close() {
if (this.pool) {
await this.pool.end();
logger.info('Database connection closed');
}
}
}
module.exports = new DatabaseService();