1416 lines
48 KiB
JavaScript
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();
|