[verified] fix(ordrestyring): enforce canonical release readiness

This commit is contained in:
alexpolo1
2026-10-04 20:23:09 +02:00
parent 22fc19aca0
commit c7293c99ad
10 changed files with 331 additions and 651 deletions
@@ -122,7 +122,7 @@ test('planning orders join current identities once and expose eligible value sep
test('canonical history ignores amplified legacy rows before limit and contributes each case once', async () => {
const cases = [{ case_number: 'A', description: 'roof A', total_hours: 1 }, { case_number: 'B', description: 'roof B', total_hours: 2 }];
const execute = jest.fn(async (sql, params) => {
if (sql.includes('ordrestyring_cases_current')) return [cases.slice(0, params.at(-1))];
if (sql.includes('ordrestyring_cases_current')) return [cases.slice(params.at(-1), params.at(-1) + params.at(-2))];
if (sql.includes('ordrestyring_material_semantics')) return [[]];
if (sql.includes('ordrestyring_local.hours')) return [[
{ case_number: 'A', start_time: 3600, stop_time: 7200 },
@@ -0,0 +1,74 @@
const fs = require('fs');
const path = require('path');
const { assertOrdrestyringReadiness } = require('../ordrestyringPreflight');
const tables = ['ordrestyring_cases_current', 'ordrestyring_debtors_current', 'ordrestyring_material_semantics', 'ordrestyring_projection_state'];
function fixture() {
const states = ['cases', 'debtors', 'materials'].map(projection_kind => ({ projection_kind, source_account: 'account', row_count: 2, sync_run_id: 'run', mirrored_at: '2026-10-04' }));
const counts = { actual_count: 2, account_count: 2 };
const db = { execute: jest.fn(async sql => {
if (sql.includes('INFORMATION_SCHEMA')) return [tables.map(table_name => ({ table_name }))];
if (sql.includes('FROM ordrestyring_local.ordrestyring_projection_state')) return [states];
return [[counts]];
}) };
return { db, states, counts };
}
test('ready canonical boot is read-only and checks all four tables', async () => {
const { db } = fixture();
await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: 'account' })).resolves.toMatchObject({ ready: true });
expect(db.execute.mock.calls.every(([sql]) => /^\s*SELECT/.test(sql))).toBe(true);
for (const table of tables) expect(JSON.stringify(db.execute.mock.calls)).toContain(table);
});
test('missing source account and invalid modes refuse boot', async () => {
await expect(assertOrdrestyringReadiness(fixture().db, {})).rejects.toThrow(/SOURCE_ACCOUNT/);
await expect(assertOrdrestyringReadiness(fixture().db, { ORDRESTYRING_READ_MODE: 'typo' })).rejects.toThrow(/mode/i);
});
test('explicit legacy rollback needs no migration but canonical domain overrides do', async () => {
const { db } = fixture();
await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_READ_MODE: 'legacy' })).resolves.toMatchObject({ ready: true });
expect(db.execute).not.toHaveBeenCalled();
await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_READ_MODE: 'legacy', ORDRESTYRING_READ_MODE_PLANNING: 'canonical' })).rejects.toThrow(/SOURCE_ACCOUNT/);
});
test.each(['missing table', 'missing state', 'wrong account', 'zero expected', 'wrong count', 'mixed accounts', 'unmirrored'])('%s refuses canonical boot', async problem => {
const { db, states, counts } = fixture();
if (problem === 'missing table') db.execute.mockResolvedValueOnce([tables.slice(1).map(table_name => ({ table_name }))]);
if (problem === 'missing state') states.pop();
if (problem === 'wrong account') states[0].source_account = 'other';
if (problem === 'zero expected') states[0].row_count = 0;
if (problem === 'wrong count') counts.actual_count = 1;
if (problem === 'mixed accounts') counts.account_count = 1;
if (problem === 'unmirrored') states[0].sync_run_id = null;
await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: 'account' })).rejects.toThrow(/Ordrestyring/);
});
test('actual server start awaits preflight before service initialization and listen', () => {
const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8');
const start = source.slice(source.indexOf('const startServer = async'));
expect(start.indexOf('await assertOrdrestyringReadiness(')).toBeGreaterThan(-1);
expect(start.indexOf('await assertOrdrestyringReadiness(')).toBeLessThan(start.indexOf('await initializeBackend()'));
expect(start.indexOf('await initializeBackend()')).toBeLessThan(start.indexOf('server.listen('));
});
test.each([false, true])('actual start function only initializes and listens after successful readiness: %s', async ready => {
const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8');
const start = source.slice(source.indexOf('const startServer = async'), source.indexOf('startServer().catch'));
const { db } = fixture();
const preflight = connection => assertOrdrestyringReadiness(connection, ready ? { ORDRESTYRING_SOURCE_ACCOUNT: 'account' } : {});
const initializeBackend = jest.fn().mockResolvedValue(true);
const server = { listen: jest.fn() };
const boot = new Function('assertOrdrestyringReadiness', 'db', 'initializeBackend', 'server', 'PORT', `${start}; return startServer;`)(preflight, db, initializeBackend, server, 4032);
if (ready) {
await boot();
expect(initializeBackend).toHaveBeenCalledTimes(1);
expect(server.listen).toHaveBeenCalledTimes(1);
} else {
await expect(boot()).rejects.toThrow(/SOURCE_ACCOUNT/);
expect(initializeBackend).not.toHaveBeenCalled();
expect(server.listen).not.toHaveBeenCalled();
}
});
test.each(['private account name', 'a'.repeat(129)])('source account must satisfy the projection account contract', async account => {
const { db, states } = fixture();
states.forEach(state => { state.source_account = account; });
await expect(assertOrdrestyringReadiness(db, { ORDRESTYRING_SOURCE_ACCOUNT: account })).rejects.toThrow(/SOURCE_ACCOUNT/);
expect(db.execute).not.toHaveBeenCalled();
});
@@ -0,0 +1,123 @@
const fs = require('fs');
const path = require('path');
const Planning = require('../planningService');
const Suggestions = require('../orderSuggestionService');
const Enhanced = require('../enhancedOrderDataService');
const { OrdrestyringReadModel, hoursRelation, planningHoursRelation } = require('../ordrestyringReadModel');
const allocation = { employee_id: 7, case_number: 'CURRENT', start_time: '2026-10-04T08:00:00Z' };
test.each(['createAllocation', 'updateAllocation'])('%s validates canonical identity and rejects frozen-only cases', async method => {
const query = jest.fn(async sql => {
if (sql.includes('ordrestyring_cases_current')) return [];
if (sql.includes('INSERT') || sql.includes('UPDATE')) return { insertId: 1 };
return [{ id: 7, case_number: 'CURRENT' }];
});
const service = new Planning({ query });
await expect(method === 'createAllocation' ? service[method](allocation) : service[method](1, allocation)).rejects.toThrow('Case not found');
expect(query.mock.calls.some(([sql]) => /INSERT|UPDATE/.test(sql))).toBe(false);
});
test('canonical-only case can be allocated and explicit legacy rollback uses raw identity', async () => {
for (const mode of ['canonical', 'legacy']) {
const query = jest.fn(async sql => {
if (sql.includes('INSERT')) return { insertId: 1 };
if (sql.includes('.cases WHERE')) return mode === 'legacy' ? [{ case_number: 'CURRENT' }] : [];
return [{ id: 7, case_number: 'CURRENT' }];
});
await expect(new Planning({ query }, { env: { ORDRESTYRING_READ_MODE: mode } }).createAllocation(allocation)).resolves.toMatchObject({ success: true });
}
});
test('planning keeps open allocations while benchmark hours remain positive-duration only', async () => {
expect(planningHoursRelation).toMatch(/stop_time = 0/);
expect(hoursRelation).not.toMatch(/stop_time = 0/);
const start = Math.floor(Date.now() / 1000);
const query = jest.fn(async sql => {
if (sql.includes('FROM ordrestyring_local.users')) return [{ id: 7 }];
if (sql.includes('stop_time = 0')) return [{ id: 1, emp_id: 7, start_time: start, stop_time: 0 }];
return [];
});
const service = new Planning({ query });
expect((await service.getEmployees())[0].upcoming_allocations).toHaveLength(1);
const weeks = Object.values(await service.getCalendarData());
expect(weeks[0].employees[7].allocations[0]).toMatchObject({ duration_hours: 0, end_date: null });
});
test('canonical candidate SQL supports bounded deterministic windows', async () => {
const execute = jest.fn().mockResolvedValue([[]]);
await new OrdrestyringReadModel({ execute }).candidates({ limit: 12, offset: 24 });
expect(execute.mock.calls[0][0]).toContain('LIMIT ? OFFSET ?');
expect(execute.mock.calls[0][1].slice(-2)).toEqual([12, 24]);
});
test('suggestions continue beyond excluded windows and retain unique positive-hour identities', async () => {
const service = new Suggestions({ pool: { execute: jest.fn() } });
const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) =>
Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: offset < 24 ? 'test roof' : 'roof', total_hours: 2 })));
const rows = await service.getOrderCandidateCases({}, ['roof'], 12);
expect(rows.filter(row => !service.getHistoryCandidateExclusions({}, row).length)).toHaveLength(12);
expect(candidates.mock.calls.map(([args]) => args.offset)).toEqual([0, 12, 24]);
});
test('canonical suggestions stop scanning at 1000 even when all candidates are excluded', async () => {
const service = new Suggestions({ pool: { execute: jest.fn() } });
const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) =>
Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: 'test roof', total_hours: 2 })));
const rows = await service.getOrderCandidateCases({}, ['roof'], 12);
expect(rows).toHaveLength(1000);
expect(candidates.mock.calls.at(-1)[0]).toMatchObject({ offset: 996, limit: 4 });
});
test.each(['findMaterials', 'searchDatabaseMaterials', 'searchAPIMaterials', 'findSimilarProjects', 'generateEstimate'])('unsafe enhanced %s fails closed before raw or API access', async method => {
const query = jest.fn();
await expect(new Enhanced({ query })[method](['findSimilarProjects', 'generateEstimate'].includes(method) ? 'roof' : { keywords: ['roof'] })).rejects.toMatchObject({ code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE' });
expect(query).not.toHaveBeenCalled();
});
test('live tag experience route fails closed without fabricated evidence', async () => {
const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8');
const body = source.split("app.get('/api/customer-projects/tag-experience-suggestions'")[1].split('// Tag Geometry Intelligence')[0];
const handler = new Function(`return async (req, res) => {${body.slice(body.indexOf('=> {') + 4, body.lastIndexOf('});'))}}`)();
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler({}, res);
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE', suggestions: [] }));
});
test('canonical history scans past windows without a post-query match', async () => {
const History = require('../historicalSmartPackageSearchService');
const service = new History({ pool: { execute: jest.fn() } });
jest.spyOn(service, 'getOrdrestyringMaterialsByCase').mockResolvedValue(new Map());
jest.spyOn(service, 'getOrdrestyringHoursByCase').mockResolvedValue(new Map());
const candidates = jest.spyOn(service.readModel, 'candidates').mockImplementation(async ({ offset = 0, limit }) =>
Array.from({ length: limit }, (_, i) => ({ case_number: String(offset + i), description: offset < 12 ? 'irrelevant' : 'roof', total_hours: 2 })));
const rows = await service.getOrdrestyringCandidates('roof', ['roof'], 12);
expect(rows.filter(row => service.calculateMatch(row, 'roof', ['roof']).hasQuerySignal)).toHaveLength(12);
expect(candidates.mock.calls.map(([args]) => args.offset)).toEqual([0, 12]);
});
test('canonical suggestions reject nonfinite hours and deduplicate identities across windows', async () => {
const service = new Suggestions({ pool: { execute: jest.fn() } });
jest.spyOn(service.readModel, 'candidates')
.mockResolvedValueOnce([
{ case_number: 'A', description: 'roof', total_hours: 1 },
{ case_number: 'BAD', description: 'roof', total_hours: Infinity }
])
.mockResolvedValueOnce([
{ case_number: 'A', description: 'roof', total_hours: 1 },
{ case_number: 'B', description: 'roof', total_hours: 2 }
]);
const rows = await service.getOrderCandidateCases({}, ['roof'], 2);
expect(rows.map(row => row.case_number)).toEqual(['A', 'B']);
});
test.each([
['/api/enhanced-quote', { projectDescription: 'roof' }],
['/api/search-materials', { keywords: ['roof'] }],
['/api/similar-projects', { description: 'roof' }]
])('live enhanced consumer %s returns unavailable without quote evidence', async (route, body) => {
const source = fs.readFileSync(path.join(__dirname, '../../../unified-server.js'), 'utf8');
const routeStart = source.indexOf(`app.post('${route}'`);
expect(routeStart).toBeGreaterThan(-1);
const handlerStart = source.indexOf('=> {', routeStart) + 4;
const handlerEnd = source.indexOf('\n});', handlerStart);
const handler = new Function('enhancedOrderDataService', 'logger', `return async (req, res) => {${source.slice(handlerStart, handlerEnd)}}`)(
new Enhanced(), { info: jest.fn(), error: jest.fn() });
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler({ body }, res);
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE' }));
});
+13 -482
View File
@@ -1,487 +1,18 @@
const mysql = require('mysql2/promise');
const axios = require('axios');
// These legacy estimators aggregate unclassified raw materials and amplified
// cases. Keep their public entry points unavailable until canonical evidence
// supports usage, prices, project totals and similarity rankings end to end.
class EnhancedOrderDataService {
constructor(databaseService) {
this.db = databaseService;
this.apiBase = 'https://v2.api.ordrestyring.dk';
this.apiToken = process.env.ORDRESTYRING_TOKEN;
}
unavailable() {
const error = new Error('Avancerede Ordrestyring-estimater mangler verificeret datagrundlag.');
error.code = 'UNVERIFIED_ORDRESTYRING_EVIDENCE';
throw error;
}
/**
* Intelligent material search: Database first, API fallback
*/
async findMaterials(searchCriteria) {
const results = {
source: 'hybrid',
database_results: [],
api_results: [],
total_matches: 0,
search_strategy: []
};
try {
// 1. Search local database first
console.log('🔍 Searching local database for materials...');
results.search_strategy.push('database_search');
const dbResults = await this.searchDatabaseMaterials(searchCriteria);
results.database_results = dbResults;
results.total_matches += dbResults.length;
console.log(`📊 Found ${dbResults.length} matches in local database`);
// 2. If we have good matches, use them
if (dbResults.length >= 3) {
results.source = 'database_primary';
return results;
}
// 3. If limited matches, supplement with API data
if (this.apiToken && dbResults.length < 5) {
console.log('🌐 Supplementing with live API data...');
results.search_strategy.push('api_supplement');
const apiResults = await this.searchAPIMaterials(searchCriteria);
results.api_results = apiResults;
results.total_matches += apiResults.length;
results.source = 'hybrid';
}
return results;
} catch (error) {
console.error('❌ Error in material search:', error);
throw error;
}
}
/**
* Search local database for materials
*/
async searchDatabaseMaterials(criteria) {
const { keywords, category, priceRange, supplier } = criteria;
try {
// Create connection to ordrestyring database
const mysql = require('mysql2/promise');
const ordrestyringDb = mysql.createPool({
host: process.env.ORDRESTYRING_DB_HOST,
user: process.env.ORDRESTYRING_DB_USER,
password: process.env.ORDRESTYRING_DB_PASSWORD,
database: process.env.ORDRESTYRING_DB_NAME,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0
});
let query = `
SELECT
cm.product_text,
COUNT(*) as usage_frequency,
ROUND(AVG(CAST(cm.sales_price AS DECIMAL(10,2))/100), 2) as price_dkk,
AVG(cm.quantity) as avg_quantity,
COUNT(DISTINCT cm.case_number) as project_count
FROM case_materials cm
WHERE cm.product_text IS NOT NULL
AND cm.product_text != ''
AND cm.sales_price > 0
`;
const params = [];
// Add keyword search
if (keywords && keywords.length > 0) {
const keywordConditions = keywords.map(() => 'cm.product_text LIKE ?').join(' OR ');
query += ` AND (${keywordConditions})`;
keywords.forEach(keyword => params.push(`%${keyword}%`));
}
// Add supplier filter
if (supplier) {
query += ` AND cm.supplier LIKE ?`;
params.push(`%${supplier}%`);
}
// Add price range
if (priceRange) {
if (priceRange.min) {
query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 >= ?`;
params.push(priceRange.min);
}
if (priceRange.max) {
query += ` AND CAST(cm.sales_price AS DECIMAL(10,2))/100 <= ?`;
params.push(priceRange.max);
}
}
query += `
GROUP BY cm.product_text
ORDER BY usage_frequency DESC, price_dkk ASC
LIMIT 20
`;
const [results] = await ordrestyringDb.execute(query, params);
// Close the connection
await ordrestyringDb.end();
// Group similar materials and calculate statistics
return this.processDatabaseResults(results);
} catch (error) {
console.error('❌ Error searching ordrestyring database:', error);
// Return empty results if database search fails
return [];
}
}
/**
* Search API for materials (fallback)
*/
async searchAPIMaterials(criteria) {
if (!this.apiToken) return [];
try {
const config = {
method: 'GET',
url: `${this.apiBase}/case-materials`,
auth: {
username: this.apiToken,
password: 'x'
},
params: {
pagesize: 50
}
};
const response = await axios(config);
const materials = Object.values(response.data || {});
// Filter API results based on criteria
return this.filterAPIResults(materials, criteria);
} catch (error) {
console.error('❌ API search failed:', error);
return [];
}
}
/**
* Process database results to add statistics
*/
processDatabaseResults(results) {
const processed = results.map(item => ({
...item,
source: 'database',
confidence: this.calculateConfidence(item),
price_stats: this.calculatePriceStats(item)
}));
return processed;
}
/**
* Filter API results based on search criteria
*/
filterAPIResults(materials, criteria) {
const { keywords } = criteria;
return materials
.filter(material => {
if (!material.product_text) return false;
if (keywords && keywords.length > 0) {
return keywords.some(keyword =>
material.product_text.toLowerCase().includes(keyword.toLowerCase())
);
}
return true;
})
.slice(0, 10)
.map(material => ({
...material,
source: 'api',
price_dkk: parseFloat(material.sales_price || 0) / 100,
confidence: 0.7 // Lower confidence for API data
}));
}
/**
* Calculate confidence score for materials
*/
calculateConfidence(item) {
let confidence = 0.5;
// Higher confidence for frequently used materials
if (item.usage_frequency > 10) confidence += 0.3;
else if (item.usage_frequency > 5) confidence += 0.2;
else if (item.usage_frequency > 2) confidence += 0.1;
// Higher confidence for recent data
if (item.case_number && parseInt(item.case_number) > 1500) confidence += 0.1;
// Higher confidence for known suppliers
if (item.supplier && item.supplier.includes('Bygma')) confidence += 0.1;
return Math.min(confidence, 1.0);
}
/**
* Calculate price statistics for a material
*/
calculatePriceStats(item) {
return {
current_price: item.price_dkk,
cost_margin: item.cost_dkk ? ((item.price_dkk - item.cost_dkk) / item.cost_dkk * 100).toFixed(1) : null,
usage_frequency: item.usage_frequency
};
}
/**
* Find similar projects based on description analysis
*/
async findSimilarProjects(description, projectType = null) {
try {
const keywords = this.extractKeywords(description);
console.log(`🔍 Finding similar projects for keywords: ${keywords.join(', ')}`);
// Build dynamic query to find similar projects
const whereConditions = [];
const params = [];
// Add keyword matching
keywords.forEach(keyword => {
whereConditions.push('c.description LIKE ?');
params.push(`%${keyword}%`);
});
// Simplified query for better performance
let query = `
SELECT
c.case_number,
c.description,
c.creation_date,
COUNT(cm.id) as material_count,
COALESCE(SUM(cm.sales_price), 0) as total_material_cost
FROM cases c
LEFT JOIN case_materials cm ON c.case_number = cm.case_number AND cm.sales_price > 0
WHERE ${whereConditions.join(' OR ')}
AND c.description IS NOT NULL
AND c.description != ''
GROUP BY c.case_number, c.description, c.creation_date
HAVING COUNT(cm.id) > 0 AND COALESCE(SUM(cm.sales_price), 0) > 1000
ORDER BY COALESCE(SUM(cm.sales_price), 0) DESC
LIMIT 10
`;
const results = await this.db.query(query, params);
// Calculate estimated totals and clean data
const projects = results.map(row => ({
case_number: row.case_number,
description: row.description || 'Ingen beskrivelse',
total_material_cost: Math.max(0, parseFloat(row.total_material_cost) || 0),
total_hours: 40, // Default estimate for labor
avg_hourly_rate: 800, // Standard hourly rate
material_count: parseInt(row.material_count) || 0,
estimated_total: 0, // Will be calculated below
creation_date: row.creation_date
}));
// Calculate estimated totals (materials + estimated labor)
projects.forEach(project => {
// Simple estimation: 40 hours labor for most projects
const laborCost = project.total_hours * project.avg_hourly_rate;
project.estimated_total = project.total_material_cost + laborCost;
});
console.log(`✅ Found ${projects.length} similar projects`);
return projects;
} catch (error) {
console.error('❌ Error finding similar projects:', error);
return [];
}
}
/**
* Extract keywords from project description
*/
extractKeywords(description) {
const commonKeywords = [
'køkken', 'bad', 'badeværelse', 'toilet', 'loft', 'gulv', 'væg', 'dør', 'vindue',
'maling', 'fliser', 'gips', 'isolering', 'el', 'vvs', 'tømrer', 'murer',
'renovering', 'ombygning', 'tilbygning', 'reparation', 'montering', 'terrasse',
'tag', 'facade', 'kælder', 'badekar', 'bruser', 'skabe', 'bordplade', 'granit',
'teak', 'træ', 'belysning', 'gelænder', 'hvidevarer', 'montage'
];
const words = description.toLowerCase().split(/\s+/);
const foundKeywords = words.filter(word => {
// Exact match with common keywords
if (commonKeywords.includes(word)) return true;
// Partial match for longer words
if (word.length > 4) {
return commonKeywords.some(keyword =>
word.includes(keyword) || keyword.includes(word)
);
}
return false;
});
// Also include longer words that might be important
const importantWords = words.filter(word => word.length > 5);
return [...new Set([...foundKeywords, ...importantWords])].slice(0, 8);
}
/**
* Calculate project similarity confidence
*/
calculateProjectConfidence(project, searchKeywords) {
let confidence = 0.3;
const projectWords = project.description.toLowerCase().split(/\s+/);
const matchingKeywords = searchKeywords.filter(keyword =>
projectWords.some(word => word.includes(keyword))
);
confidence += (matchingKeywords.length / searchKeywords.length) * 0.4;
// Recent projects get higher confidence
if (project.case_number && parseInt(project.case_number) > 1500) confidence += 0.2;
// Projects with more materials get higher confidence
if (project.material_count > 20) confidence += 0.1;
return Math.min(confidence, 1.0);
}
/**
* Generate comprehensive estimate
*/
async generateEstimate(projectDescription, requirements = {}) {
console.log('🎯 Generating comprehensive estimate...');
const estimate = {
project_description: projectDescription,
timestamp: new Date().toISOString(),
data_sources: [],
materials: {
database_matches: [],
api_supplements: [],
estimated_cost: 0
},
labor: {
estimated_hours: 0,
hourly_rate: 0,
total_cost: 0
},
similar_projects: [],
total_estimate: 0,
confidence_score: 0
};
try {
// 1. Find similar projects
const similarProjects = await this.findSimilarProjects(projectDescription);
estimate.similar_projects = similarProjects;
estimate.data_sources.push({
type: 'historical_projects',
count: similarProjects.length,
confidence: similarProjects.length > 0 ? Math.min(90, 30 + (similarProjects.length * 10)) : 30
});
// 2. Search for relevant materials
const materialCriteria = {
keywords: this.extractKeywords(projectDescription),
priceRange: requirements.budget ? { max: requirements.budget * 0.6 } : null
};
const materialResults = await this.findMaterials(materialCriteria);
estimate.materials.database_matches = materialResults.database_results;
estimate.materials.api_supplements = materialResults.api_results;
estimate.data_sources.push({
type: materialResults.source,
count: materialResults.total_matches,
confidence: materialResults.database_results.length > 0 ?
Math.min(95, 50 + (materialResults.database_results.length * 2)) : 30
});
// 3. Calculate estimates based on similar projects
if (similarProjects.length > 0) {
const validProjects = similarProjects.filter(p =>
p.total_material_cost >= 0 &&
p.total_hours >= 0 &&
p.total_hours <= 1000 && // Reasonable upper limit
p.avg_hourly_rate >= 100 &&
p.avg_hourly_rate <= 3000
);
if (validProjects.length > 0) {
const avgMaterialCost = validProjects.reduce((sum, p) => sum + (p.total_material_cost || 0), 0) / validProjects.length;
const avgHours = validProjects.reduce((sum, p) => sum + (p.total_hours || 0), 0) / validProjects.length;
const avgHourlyRate = validProjects.reduce((sum, p) => sum + (p.avg_hourly_rate || 800), 0) / validProjects.length;
estimate.materials.estimated_cost = Math.round(Math.max(0, avgMaterialCost));
estimate.labor.estimated_hours = Math.round(Math.max(0, Math.min(500, avgHours))); // Cap at 500 hours
estimate.labor.hourly_rate = Math.round(Math.max(400, Math.min(2000, avgHourlyRate))); // 400-2000 kr/hour
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
} else {
// Fallback estimates if no valid projects
estimate.materials.estimated_cost = Math.round((options.budget || 100000) * 0.6); // 60% materials
estimate.labor.estimated_hours = Math.round((options.budget || 100000) / 800 * 0.4 / 800); // 40% labor
estimate.labor.hourly_rate = 800;
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
}
} else {
// No similar projects found - use budget-based estimates
const budgetBased = requirements.budget || 100000;
estimate.materials.estimated_cost = Math.round(budgetBased * 0.65);
estimate.labor.estimated_hours = Math.round(budgetBased * 0.35 / 800);
estimate.labor.hourly_rate = 800;
estimate.labor.total_cost = Math.round(estimate.labor.estimated_hours * estimate.labor.hourly_rate);
}
// 4. Calculate total and confidence
estimate.total_estimate = Math.round(estimate.materials.estimated_cost + estimate.labor.total_cost);
estimate.confidence_score = this.calculateOverallConfidence(estimate);
console.log(`✅ Estimate generated: ${estimate.total_estimate} kr (confidence: ${(estimate.confidence_score * 100).toFixed(1)}%)`);
return estimate;
} catch (error) {
console.error('❌ Error generating estimate:', error);
throw error;
}
}
/**
* Calculate overall confidence for the estimate
*/
calculateOverallConfidence(estimate) {
let confidence = 0.2;
// More similar projects = higher confidence
if (estimate.similar_projects.length > 3) confidence += 0.3;
else if (estimate.similar_projects.length > 1) confidence += 0.2;
// More material matches = higher confidence
if (estimate.materials.database_matches.length > 5) confidence += 0.2;
else if (estimate.materials.database_matches.length > 2) confidence += 0.1;
// Recent data = higher confidence
const recentProjects = estimate.similar_projects.filter(p => parseInt(p.case_number) > 1500);
if (recentProjects.length > 0) confidence += 0.2;
// Multiple data sources = higher confidence
if (estimate.data_sources.length > 1) confidence += 0.1;
return Math.min(confidence, 1.0);
}
async findMaterials() { return this.unavailable(); }
async searchDatabaseMaterials() { return this.unavailable(); }
async searchAPIMaterials() { return this.unavailable(); }
async findSimilarProjects() { return this.unavailable(); }
async generateEstimate() { return this.unavailable(); }
}
module.exports = EnhancedOrderDataService;
@@ -509,9 +509,9 @@ class HistoricalSmartPackageSearchService {
}];
}
async getOrdrestyringCaseCandidates(query, tokens, candidateLimit) {
async getOrdrestyringCaseCandidates(query, tokens, candidateLimit, offset = 0) {
return this.readModel.read('history', () => this.getOrdrestyringCaseCandidatesLegacy(query, tokens, candidateLimit), async () => {
return this.readModel.candidates({ tokens: [query, ...tokens], limit: candidateLimit });
return this.readModel.candidates({ tokens: [query, ...tokens], limit: candidateLimit, offset });
});
}
@@ -971,8 +971,30 @@ class HistoricalSmartPackageSearchService {
}));
}
async getOrdrestyringCandidates(query, tokens, candidateLimit) {
const caseCandidates = await this.getOrdrestyringCaseCandidates(query, tokens, candidateLimit);
async getOrdrestyringCandidates(query, tokens, candidateLimit, eligibleLimit = candidateLimit) {
if (this.readModel.mode('history') !== 'canonical') {
return this.getOrdrestyringCandidateWindow(query, tokens, candidateLimit);
}
const candidates = new Map();
let eligibleCount = 0;
for (let offset = 0; offset < 1000;) {
const windowSize = Math.min(Math.max(1, candidateLimit), 1000 - offset);
const rows = await this.getOrdrestyringCandidateWindow(query, tokens, windowSize, offset);
for (const row of rows) {
if (candidates.has(row.case_number) || !Number.isFinite(Number(row.total_work_hours))
|| !(Number(row.total_work_hours) > 0)) continue;
candidates.set(row.case_number, row);
const match = this.calculateMatch(row, query, tokens);
if (match.score > 0 && match.hasQuerySignal) eligibleCount += 1;
}
offset += windowSize;
if (eligibleCount >= eligibleLimit || rows.length < windowSize) break;
}
return [...candidates.values()];
}
async getOrdrestyringCandidateWindow(query, tokens, candidateLimit, offset = 0) {
const caseCandidates = await this.getOrdrestyringCaseCandidates(query, tokens, candidateLimit, offset);
const caseNumbers = caseCandidates.map(candidate => candidate.case_number).filter(Boolean);
const [materialsByCase, hoursByCase, offerCandidates] = await Promise.all([
this.getOrdrestyringMaterialsByCase(caseNumbers),
@@ -1035,7 +1057,7 @@ class HistoricalSmartPackageSearchService {
})
);
const ordrestyringCandidates = await this.getOrdrestyringCandidates(normalizedQuery, tokens, candidateLimit);
const ordrestyringCandidates = await this.getOrdrestyringCandidates(normalizedQuery, tokens, candidateLimit, limit);
const enrichedOrdrestyringCandidates = ordrestyringCandidates.map((candidate) => {
const matchInfo = this.calculateMatch(candidate, normalizedQuery, tokens);
return {
+19 -3
View File
@@ -1044,9 +1044,25 @@ class OrderSuggestionService {
};
}
async getOrderCandidateCases(project, tokens, candidateLimit) {
async getOrderCandidateCases(project, tokens, candidateLimit, eligibleLimit = candidateLimit) {
return this.readModel.read('suggestions', () => this.getOrderCandidateCasesLegacy(project, tokens, candidateLimit), async () => {
return this.readModel.candidates({ tokens, customerNumber: project.customer_number, customerName: project.customer_name, limit: candidateLimit });
const candidates = new Map();
let eligibleCount = 0;
for (let offset = 0; offset < 1000;) {
const windowSize = Math.min(Math.max(1, candidateLimit), 1000 - offset);
const rows = await this.readModel.candidates({ tokens, customerNumber: project.customer_number,
customerName: project.customer_name, limit: windowSize, offset });
for (const row of rows) {
if (candidates.has(row.case_number) || !Number.isFinite(Number(row.total_hours))
|| !(Number(row.total_hours) > 0)) continue;
candidates.set(row.case_number, row);
if (!this.getHistoryCandidateExclusions(project, row).length
&& this.calculateOrderCandidateScore(project, row, tokens).relevanceScore > 0) eligibleCount += 1;
}
offset += windowSize;
if (eligibleCount >= eligibleLimit || rows.length < windowSize) break;
}
return [...candidates.values()];
});
}
@@ -1583,7 +1599,7 @@ class OrderSuggestionService {
let orderCandidates = [];
let ordrestyringFallbackWarning = null;
try {
orderCandidates = await this.getOrderCandidateCases(project, tokens, candidateLimit);
orderCandidates = await this.getOrderCandidateCases(project, tokens, candidateLimit, limit);
} catch (error) {
if (this.readModel.mode('suggestions') === 'canonical') throw error;
ordrestyringFallbackWarning = 'Ordrestyring historik kunne ikke hentes nu. Viser lokale projekter i stedet.';
@@ -0,0 +1,38 @@
const { OrdrestyringReadModel } = require('./ordrestyringReadModel');
const projections = {
cases: 'ordrestyring_cases_current',
debtors: 'ordrestyring_debtors_current',
materials: 'ordrestyring_material_semantics'
};
// Read-only boot gate. Operators must run the explicit migration before boot;
// HTTP requests and startup must never repair an absent or partial projection.
async function assertOrdrestyringReadiness(connection, env = process.env) {
const model = new OrdrestyringReadModel(connection, { env });
if (Object.values(model.modes).every(mode => mode === 'legacy')) return { ready: true, mode: 'legacy' };
const account = env.ORDRESTYRING_SOURCE_ACCOUNT;
if (typeof account !== 'string' || !/^[a-zA-Z0-9._:-]{1,128}$/.test(account)) {
throw new Error('Ordrestyring preflight requires ORDRESTYRING_SOURCE_ACCOUNT');
}
const fail = reason => { throw new Error(`Ordrestyring canonical preflight failed: ${reason}. Run the explicit canonical migration before starting.`); };
const tables = [...Object.values(projections), 'ordrestyring_projection_state'];
const rows = await model.rows(`SELECT TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'ordrestyring_local' AND TABLE_NAME IN (?, ?, ?, ?)`, tables);
if (!tables.every(table => rows.some(row => row.table_name === table))) fail('missing canonical tables');
const states = await model.rows(`SELECT projection_kind, source_account, row_count, sync_run_id, mirrored_at
FROM ordrestyring_local.ordrestyring_projection_state`);
for (const [kind, table] of Object.entries(projections)) {
const state = states.find(row => row.projection_kind === kind);
if (!state || state.source_account !== account || !state.sync_run_id || !state.mirrored_at
|| !Number.isSafeInteger(Number(state.row_count)) || Number(state.row_count) <= 0) fail(`${kind} projection is not ready`);
const [counts] = await model.rows(`SELECT COUNT(*) AS actual_count,
SUM(CASE WHEN BINARY source_account = BINARY ? THEN 1 ELSE 0 END) AS account_count
FROM ordrestyring_local.${table}`, [account]);
if (Number(counts?.actual_count) !== Number(state.row_count)
|| Number(counts?.account_count) !== Number(state.row_count)) fail(`${kind} count/account mismatch`);
}
return { ready: true, mode: 'canonical' };
}
module.exports = { assertOrdrestyringReadiness };
@@ -19,6 +19,8 @@ const casesRelation = `(SELECT case_number, case_number AS id, ${fields(['custom
const debtorsRelation = `(SELECT customer_number, ${fields(['customer_name', 'customer_address', 'customer_telephone', 'customer_email'])} FROM ordrestyring_local.ordrestyring_debtors_current)`;
const seconds = name => `(CASE WHEN ${name} > 100000000000 THEN ${name} / 1000 ELSE ${name} END)`;
const hoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours WHERE start_time > 0 AND ${seconds('stop_time')} > ${seconds('start_time')})`;
// Planning also represents local allocations whose end is not known yet.
const planningHoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours WHERE start_time > 0 AND (stop_time = 0 OR ${seconds('stop_time')} > ${seconds('start_time')}))`;
const parse = value => typeof value === 'string' ? JSON.parse(value) : value;
class OrdrestyringReadModel {
@@ -92,7 +94,7 @@ class OrdrestyringReadModel {
}
/** Unique current relations and preaggregated hours prevent crowd-out before LIMIT. @returns {Promise<CaseReadRow[]>} */
async candidates({ tokens = [], customerNumber, customerName, limit = 40, regex } = {}) {
async candidates({ tokens = [], customerNumber, customerName, limit = 40, offset = 0, regex } = {}) {
const clauses = [];
const params = [];
if (customerNumber) { clauses.push('c.customer_number = ?'); params.push(customerNumber); }
@@ -108,7 +110,7 @@ class OrdrestyringReadModel {
JOIN (SELECT new_case_number, SUM((stop_time - start_time) / 3600) AS total_hours, COUNT(*) AS hour_entries
FROM ${hoursRelation} verified_hours GROUP BY new_case_number) h ON h.new_case_number = c.case_number
WHERE ${clauses.length ? `(${clauses.join(' OR ')})` : '1=1'}
ORDER BY latest_activity_at DESC, c.case_number ASC LIMIT ?`, [...params, Math.max(1, Math.min(1000, Math.floor(limit)))]);
ORDER BY latest_activity_at DESC, c.case_number ASC LIMIT ? OFFSET ?`, [...params, Math.max(1, Math.min(1000, Math.floor(limit))), Math.max(0, Math.min(1000, Math.floor(offset)))]);
}
async hours(numbers) {
@@ -172,4 +174,4 @@ class OrdrestyringReadModel {
}
}
module.exports = { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation };
module.exports = { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation, planningHoursRelation };
+14 -14
View File
@@ -1,4 +1,4 @@
const { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation } = require('./ordrestyringReadModel');
const { OrdrestyringReadModel, casesRelation, debtorsRelation, hoursRelation, planningHoursRelation } = require('./ordrestyringReadModel');
const logger = require('../utils/logger');
/**
@@ -164,7 +164,7 @@ class PlanningService {
h.new_case_number,
c.description as case_description,
c.yourref
FROM ${canonical ? hoursRelation : `${this.ORDRESTYRING_DB}.hours`} h
FROM ${canonical ? planningHoursRelation : `${this.ORDRESTYRING_DB}.hours`} h
LEFT JOIN ${canonical ? casesRelation : `${this.ORDRESTYRING_DB}.cases`} c ON h.new_case_number = c.case_number
WHERE h.emp_id = ?
AND h.start_time >= ?
@@ -240,7 +240,7 @@ class PlanningService {
u.first_name,
u.last_name,
u.init
FROM ${canonical ? hoursRelation : `${this.ORDRESTYRING_DB}.hours`} h
FROM ${canonical ? planningHoursRelation : `${this.ORDRESTYRING_DB}.hours`} h
LEFT JOIN ${canonical ? casesRelation : `${this.ORDRESTYRING_DB}.cases`} c ON h.new_case_number = c.case_number
LEFT JOIN ${this.ORDRESTYRING_DB}.case_statuses cs ON c.status = cs.id
LEFT JOIN ${this.ORDRESTYRING_DB}.users u ON h.emp_id = u.id
@@ -308,6 +308,14 @@ class PlanningService {
}
}
async validateCase(caseNumber) {
if (!caseNumber) return;
const cases = await this.readModel.read('planning',
() => this.db.query(`SELECT case_number FROM ${this.ORDRESTYRING_DB}.cases WHERE case_number = ?`, [caseNumber]),
() => this.readModel.cases([caseNumber]));
if (!cases.length) throw new Error('Case not found');
}
/**
* Opret ny medarbejder allokering
* @param {Object} allocationData - Allocation details
@@ -334,17 +342,7 @@ class PlanningService {
throw new Error('Employee not found or inactive');
}
// Validate case exists
if (case_number) {
const caseCheck = await this.db.query(
`SELECT case_number FROM ${this.ORDRESTYRING_DB}.cases WHERE case_number = ?`,
[case_number]
);
if (caseCheck.length === 0) {
throw new Error('Case not found');
}
}
await this.validateCase(case_number);
// Create allocation entry
const insertQuery = `
@@ -408,6 +406,8 @@ class PlanningService {
case_number
} = updateData;
await this.validateCase(case_number);
const updateQuery = `
UPDATE ${this.ORDRESTYRING_DB}.hours
SET
+17 -143
View File
@@ -170,6 +170,7 @@ const healthPool = mysql.createPool({
// Import logger
const logger = require('./src/utils/logger');
const { assertOrdrestyringReadiness } = require('./src/services/ordrestyringPreflight');
const { bootstrapServiceContainer } = require('./src/services/bootstrapServiceContainer');
const { calculateQuoteEconomics } = require('./src/services/quoteEconomicsService');
const { sendPdfDownload } = require('./src/services/pdfResponseService');
@@ -5042,149 +5043,12 @@ app.get('/health', (req, res) => {
// Tag experience suggestions - must come before parametrized routes
app.get('/api/customer-projects/tag-experience-suggestions', verifyToken, requireConfiguredProjectOperator, async (req, res) => {
try {
console.log('🏷️ Fetching tag experience suggestions from Ordrestyring database...');
let laborSuggestions = [];
// Hent faktiske tag-cases fra Ordrestyring database
try {
console.log('📊 Querying ordrestyring_local database for tag cases...');
// Use the existing databaseService with cross-database query
const tagCases = await databaseService.query(`
SELECT
c.case_number,
c.description,
c.work_done,
c.remarks
FROM ordrestyring_local.cases c
WHERE c.description REGEXP 'tag|eternit|tegl|rende|velux|rygning'
AND c.case_number IS NOT NULL
AND c.description IS NOT NULL
AND c.description != ''
ORDER BY c.creation_date DESC
LIMIT 15
`);
console.log(`📋 Found ${tagCases.length} actual tag cases from Ordrestyring`);
// Transform Ordrestyring cases til labor suggestions
for (const tagCase of tagCases) {
const hours = 12; // Default reasonable hours for tag work
const hourlyRate = 550; // Standard rate
const totalCost = hours * hourlyRate;
// Extract main task from description
let taskDescription = tagCase.description;
if (taskDescription && taskDescription.length > 100) {
taskDescription = taskDescription.substring(0, 97) + '...';
}
laborSuggestions.push({
task_description: taskDescription || 'Tag arbejde',
estimated_hours: hours,
hourly_rate: hourlyRate,
total_cost: totalCost,
category: 'tag_arbejde',
confidence: 0.8, // Good confidence - real case data
source: 'ordrestyring_database',
case_number: tagCase.case_number,
work_notes: tagCase.work_done || tagCase.remarks || 'Ingen noter tilgængelige'
});
}
console.log(`✅ Converted ${laborSuggestions.length} cases to labor suggestions`);
} catch (dbError) {
console.error('❌ Database error when fetching tag cases:', dbError);
// Continue to fallback
}
// Final fallback to default suggestions
if (laborSuggestions.length === 0) {
console.log('🔄 Using default tag suggestions');
laborSuggestions = [
{
task_description: 'Skift af B7 eternitplader - standard tag',
estimated_hours: 16,
hourly_rate: 550,
total_cost: 8800,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Reparation af tagrender og nedløb',
estimated_hours: 8,
hourly_rate: 500,
total_cost: 4000,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Montering af Velux ovenlysvindue',
estimated_hours: 6,
hourly_rate: 580,
total_cost: 3480,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
}
];
}
console.log(`📋 Returning ${laborSuggestions.length} tag experience suggestions`);
res.json({
success: true,
suggestions: laborSuggestions,
message: `Fundet ${laborSuggestions.length} erfaringer fra tidligere tag-opgaver`,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error fetching tag experience suggestions:', error);
// Fallback to default suggestions if everything fails
const defaultSuggestions = [
{
task_description: 'Skift af B7 eternitplader - standard tag',
estimated_hours: 16,
hourly_rate: 550,
total_cost: 8800,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Reparation af tagrender og nedløb',
estimated_hours: 8,
hourly_rate: 500,
total_cost: 4000,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
},
{
task_description: 'Montering af Velux ovenlysvindue',
estimated_hours: 6,
hourly_rate: 580,
total_cost: 3480,
category: 'tag_arbejde',
confidence: 0.6,
source: 'default_template'
}
];
res.json({
success: true,
suggestions: defaultSuggestions,
message: 'Viser standard tag-opgave skabeloner (historiske data ikke tilgængelige)',
timestamp: new Date().toISOString()
});
}
return res.status(503).json({
success: false,
code: 'UNVERIFIED_ORDRESTYRING_EVIDENCE',
suggestions: [],
message: 'Historiske tagforslag er deaktiveret, indtil verificeret timegrundlag er tilgængeligt.'
});
});
// Tag Geometry Intelligence - Smart estimater baseret på lokal Ordrestyring database
@@ -10336,6 +10200,9 @@ app.post('/api/enhanced-quote', async (req, res) => {
} catch (error) {
logger.error('❌ Enhanced quote generation failed:', error);
if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') {
return res.status(503).json({ success: false, code: error.code, error: error.message });
}
res.status(500).json({
error: 'Fejl ved generering af avanceret tilbud',
details: error.message,
@@ -10376,6 +10243,9 @@ app.post('/api/search-materials', async (req, res) => {
} catch (error) {
logger.error('❌ Material search failed:', error);
if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') {
return res.status(503).json({ success: false, code: error.code, error: error.message });
}
res.status(500).json({
error: 'Fejl ved materialesøgning',
details: error.message
@@ -10409,6 +10279,9 @@ app.post('/api/similar-projects', async (req, res) => {
} catch (error) {
logger.error('❌ Similar projects search failed:', error);
if (error.code === 'UNVERIFIED_ORDRESTYRING_EVIDENCE') {
return res.status(503).json({ success: false, code: error.code, error: error.message });
}
res.status(500).json({
error: 'Fejl ved søgning efter lignende projekter',
details: error.message
@@ -11319,6 +11192,7 @@ app.get('*', (req, res) => {
// Start server
const startServer = async () => {
await assertOrdrestyringReadiness(db);
// Initialize backend services
const backendReady = await initializeBackend();
if (!backendReady) throw new Error('Backend initialization failed; refusing to listen');