[verified] fix(ordrestyring): enforce canonical release readiness
This commit is contained in:
@@ -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' }));
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user