feat: persist carpenter workflow status and price freshness
This commit is contained in:
@@ -6,16 +6,36 @@ CREATE TABLE IF NOT EXISTS customer_projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_name VARCHAR(255) NOT NULL,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
customer_number VARCHAR(50),
|
||||
customer_email VARCHAR(255),
|
||||
customer_phone VARCHAR(50),
|
||||
customer_address TEXT,
|
||||
project_description TEXT,
|
||||
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
|
||||
selected_packages JSON,
|
||||
suggestion_snapshot JSON,
|
||||
input_normalization_log JSON,
|
||||
project_status ENUM(
|
||||
'draft',
|
||||
'geometry_complete',
|
||||
'smart_package_complete',
|
||||
'review_pending',
|
||||
'ready_for_ordrestyring',
|
||||
'sent_to_ordrestyring',
|
||||
'accepted',
|
||||
'rejected',
|
||||
'geometry_pending',
|
||||
'labor_pending',
|
||||
'materials_pending',
|
||||
'calculation_ready',
|
||||
'quote_generated',
|
||||
'sent'
|
||||
) DEFAULT 'draft',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (project_status),
|
||||
INDEX idx_customer (customer_name),
|
||||
INDEX idx_customer_number (customer_number),
|
||||
INDEX idx_created (created_at)
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const MaterialPriceStatusService = require('../services/materialPriceStatusService');
|
||||
|
||||
describe('MaterialPriceStatusService', () => {
|
||||
test('selects the newest available source as primary source', async () => {
|
||||
const db = {
|
||||
query: jest.fn(async (sql) => {
|
||||
if (sql.includes('FROM bygma_import_log')) {
|
||||
return [{
|
||||
started_at: '2026-03-25T08:00:00.000Z',
|
||||
completed_at: '2026-03-25T08:10:00.000Z',
|
||||
status: 'completed',
|
||||
imported_by: 'bygma-user',
|
||||
filename: 'bygma.csv',
|
||||
updated_prices: 120,
|
||||
successful_rows: 900
|
||||
}];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM bygma_price_history')) {
|
||||
return [{ latest_price_at: '2026-03-25T08:00:00.000Z' }];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM import_logs')) {
|
||||
return [{
|
||||
started_at: '2026-03-20T08:00:00.000Z',
|
||||
completed_at: '2026-03-20T08:10:00.000Z',
|
||||
status: 'completed',
|
||||
imported_by: 'stark-user',
|
||||
filename: 'stark.csv',
|
||||
updated_items: 45,
|
||||
total_items: 300
|
||||
}];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM stark_materials_cache')) {
|
||||
return [{ latest_price_at: '2026-03-20T08:00:00.000Z' }];
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
})
|
||||
};
|
||||
|
||||
const service = new MaterialPriceStatusService(db);
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result.primarySource).toMatchObject({
|
||||
source: 'Bygma',
|
||||
importStatus: 'completed',
|
||||
filename: 'bygma.csv'
|
||||
});
|
||||
expect(result.sources).toHaveLength(2);
|
||||
expect(result.sources[0].statusLabel || result.sources[1].statusLabel).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns missing status when import tables are unavailable', async () => {
|
||||
const db = {
|
||||
query: jest.fn(async () => {
|
||||
throw new Error("Table 'tilbudgivern.bygma_import_log' doesn't exist");
|
||||
})
|
||||
};
|
||||
|
||||
const service = new MaterialPriceStatusService(db);
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result.primarySource).toBeNull();
|
||||
expect(result.sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: 'Bygma',
|
||||
available: false,
|
||||
freshness: 'missing'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: 'Stark',
|
||||
available: false,
|
||||
freshness: 'missing'
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
const OrderSuggestionService = require('../services/orderSuggestionService');
|
||||
|
||||
describe('OrderSuggestionService', () => {
|
||||
test('builds a prefilled recommendation from local history and ordrestyring', async () => {
|
||||
const execute = jest.fn(async (sql) => {
|
||||
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
|
||||
return [[{
|
||||
id: 7,
|
||||
project_name: 'Nyt tag B7',
|
||||
project_description: 'Udskiftning af B7 eternittag på villa',
|
||||
customer_name: 'Mikael Holck',
|
||||
customer_number: 'C100',
|
||||
roof_type: 'skraat_tag',
|
||||
total_area: 132
|
||||
}]];
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT\n cp.id AS project_id')) {
|
||||
return [[{
|
||||
project_id: 5,
|
||||
project_name: 'Historisk B7 projekt',
|
||||
project_description: 'Komplet udskiftning af B7 tag med undertag',
|
||||
customer_name: 'Mikael Holck',
|
||||
customer_number: 'C100',
|
||||
project_status: 'accepted',
|
||||
selected_packages: JSON.stringify({ id: 'b7_tag_udskiftning', name: 'B7 Tag Udskiftning - Komplet' }),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
roof_type: 'skraat_tag',
|
||||
total_area: 120,
|
||||
total_work_hours: 42,
|
||||
hourly_rate: 580,
|
||||
work_breakdown: JSON.stringify([
|
||||
{ task: 'Tagplader montering', description: 'Montering af B7 plader', hours: 18 }
|
||||
]),
|
||||
labor_notes: 'Historisk reference'
|
||||
}]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM project_materials')) {
|
||||
return [[
|
||||
{
|
||||
project_id: 5,
|
||||
material_name: 'Eternit B7 tagplader',
|
||||
material_category: 'Tagmateriale',
|
||||
quantity: 1.1,
|
||||
unit: 'm²',
|
||||
unit_price: 245
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM generated_quotes')) {
|
||||
return [[
|
||||
{
|
||||
project_id: 5,
|
||||
quote_text: 'Historisk kundevendt tilbud på B7-tag',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM ordrestyring_local.cases')) {
|
||||
return [[
|
||||
{
|
||||
case_number: '5001',
|
||||
customer_number: 'C100',
|
||||
customer_name: 'Mikael Holck',
|
||||
description: 'Udskiftning af B7 tag på villa',
|
||||
remarks: 'Komplet tagarbejde med stillads',
|
||||
creation_date: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
total_hours: 42,
|
||||
hour_entries: 8
|
||||
},
|
||||
{
|
||||
case_number: '5002',
|
||||
customer_number: 'C200',
|
||||
customer_name: 'Anden kunde',
|
||||
description: 'Tagreparation omkring kip',
|
||||
remarks: '',
|
||||
creation_date: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
total_hours: 12,
|
||||
hour_entries: 2
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
});
|
||||
|
||||
const service = new OrderSuggestionService({
|
||||
pool: { execute }
|
||||
});
|
||||
|
||||
const result = await service.getOrderSuggestions(7, { limit: 5 });
|
||||
|
||||
expect(result.customerNumber).toBe('C100');
|
||||
expect(result.matches.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.matches[0]).toMatchObject({
|
||||
sourceType: 'local_project',
|
||||
projectName: 'Historisk B7 projekt',
|
||||
sameCustomer: true
|
||||
});
|
||||
expect(result.recommendation.confidence).toBe('high');
|
||||
expect(result.recommendation.suggestedDescription).toContain('Udskiftning af B7 eternittag');
|
||||
expect(result.recommendation.recommendedPackage).toMatchObject({
|
||||
packageId: 'b7_tag_udskiftning'
|
||||
});
|
||||
expect(result.materialsDraft[0]).toMatchObject({
|
||||
name: 'Eternit B7 tagplader',
|
||||
unit: 'm²'
|
||||
});
|
||||
expect(result.laborDraft[0]).toMatchObject({
|
||||
name: 'Tagplader montering'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a low-confidence fallback when no cases match', async () => {
|
||||
const execute = jest.fn(async (sql) => {
|
||||
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
|
||||
return [[{
|
||||
id: 11,
|
||||
project_name: 'Ny terrasse',
|
||||
project_description: '',
|
||||
customer_name: 'Jens Hansen',
|
||||
customer_number: null,
|
||||
roof_type: null,
|
||||
total_area: null
|
||||
}]];
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT\n cp.id AS project_id')) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM ordrestyring_local.cases')) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
});
|
||||
|
||||
const service = new OrderSuggestionService({
|
||||
pool: { execute }
|
||||
});
|
||||
|
||||
const result = await service.getOrderSuggestions(11, { limit: 5 });
|
||||
|
||||
expect(result.matches).toEqual([]);
|
||||
expect(result.recommendation.confidence).toBe('low');
|
||||
expect(result.recommendation.averageHistoricalHours).toBe(0);
|
||||
expect(result.recommendation.summary).toContain('Ingen lokale historiske sager');
|
||||
expect(result.materialsDraft).toEqual([]);
|
||||
expect(result.laborDraft).toEqual([]);
|
||||
});
|
||||
|
||||
test('infers tegl smart package from ordrestyring history when no local package is stored', async () => {
|
||||
const execute = jest.fn(async (sql) => {
|
||||
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
|
||||
return [[{
|
||||
id: 21,
|
||||
project_name: 'Omlægning af tegltag',
|
||||
project_description: 'Renovering af tegltag på villa med ny rygning',
|
||||
customer_name: 'Erik Andersen',
|
||||
customer_number: 'C310',
|
||||
roof_type: 'skraat_tag',
|
||||
total_area: 184
|
||||
}]];
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT\n cp.id AS project_id')) {
|
||||
return [[{
|
||||
project_id: 18,
|
||||
project_name: 'Historisk tegltag',
|
||||
project_description: 'Omlægning af tegltag med undertag',
|
||||
customer_name: 'Poul Eriksen',
|
||||
customer_number: 'C311',
|
||||
project_status: 'accepted',
|
||||
selected_packages: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
roof_type: 'skraat_tag',
|
||||
total_area: 176,
|
||||
total_work_hours: 108,
|
||||
hourly_rate: 580,
|
||||
work_breakdown: JSON.stringify([
|
||||
{ task: 'Montering af tegltag', description: 'Nye tegl og afslutninger', hours: 46 }
|
||||
]),
|
||||
labor_notes: 'Historisk tegl reference'
|
||||
}]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM project_materials')) {
|
||||
return [[
|
||||
{
|
||||
project_id: 18,
|
||||
material_name: 'Tegl vingetagsten',
|
||||
material_category: 'Tagmateriale',
|
||||
quantity: 185,
|
||||
unit: 'm²',
|
||||
unit_price: 145
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM generated_quotes')) {
|
||||
return [[
|
||||
{
|
||||
project_id: 18,
|
||||
quote_text: 'Historisk kundevendt tilbud på tegltag',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM ordrestyring_local.cases')) {
|
||||
return [[
|
||||
{
|
||||
case_number: '5305',
|
||||
customer_number: 'C310',
|
||||
customer_name: 'Erik Andersen',
|
||||
description: 'Omlægning af tegltag med undertag',
|
||||
remarks: 'Klassisk villa med ny rygning',
|
||||
creation_date: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
total_hours: 118,
|
||||
hour_entries: 14
|
||||
}
|
||||
]];
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
});
|
||||
|
||||
const service = new OrderSuggestionService({
|
||||
pool: { execute }
|
||||
});
|
||||
|
||||
const result = await service.getOrderSuggestions(21, { limit: 5 });
|
||||
|
||||
expect(result.recommendation.recommendedPackage).toMatchObject({
|
||||
packageId: 'tegl_tag_renovering',
|
||||
name: 'Tegltag Renovering'
|
||||
});
|
||||
expect(result.matches[0].description).toContain('tegltag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const PROJECT_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
GEOMETRY_COMPLETE: 'geometry_complete',
|
||||
SMART_PACKAGE_COMPLETE: 'smart_package_complete',
|
||||
REVIEW_PENDING: 'review_pending',
|
||||
READY_FOR_ORDRESTYRING: 'ready_for_ordrestyring',
|
||||
SENT_TO_ORDRESTYRING: 'sent_to_ordrestyring',
|
||||
ACCEPTED: 'accepted',
|
||||
REJECTED: 'rejected',
|
||||
|
||||
// Legacy values kept for compatibility with older rows and routes.
|
||||
GEOMETRY_PENDING: 'geometry_pending',
|
||||
LABOR_PENDING: 'labor_pending',
|
||||
MATERIALS_PENDING: 'materials_pending',
|
||||
CALCULATION_READY: 'calculation_ready',
|
||||
QUOTE_GENERATED: 'quote_generated',
|
||||
SENT: 'sent'
|
||||
};
|
||||
|
||||
const ACTIVE_WORKFLOW_PROJECT_STATUSES = [
|
||||
PROJECT_STATUS.DRAFT,
|
||||
PROJECT_STATUS.GEOMETRY_COMPLETE,
|
||||
PROJECT_STATUS.SMART_PACKAGE_COMPLETE,
|
||||
PROJECT_STATUS.REVIEW_PENDING,
|
||||
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
|
||||
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
|
||||
PROJECT_STATUS.ACCEPTED,
|
||||
PROJECT_STATUS.REJECTED
|
||||
];
|
||||
|
||||
const LEGACY_PROJECT_STATUSES = [
|
||||
PROJECT_STATUS.GEOMETRY_PENDING,
|
||||
PROJECT_STATUS.LABOR_PENDING,
|
||||
PROJECT_STATUS.MATERIALS_PENDING,
|
||||
PROJECT_STATUS.CALCULATION_READY,
|
||||
PROJECT_STATUS.QUOTE_GENERATED,
|
||||
PROJECT_STATUS.SENT
|
||||
];
|
||||
|
||||
const ALL_PROJECT_STATUSES = [
|
||||
...ACTIVE_WORKFLOW_PROJECT_STATUSES,
|
||||
...LEGACY_PROJECT_STATUSES
|
||||
];
|
||||
|
||||
const QUOTE_READY_PROJECT_STATUSES = [
|
||||
PROJECT_STATUS.REVIEW_PENDING,
|
||||
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
|
||||
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
|
||||
PROJECT_STATUS.QUOTE_GENERATED,
|
||||
PROJECT_STATUS.SENT,
|
||||
PROJECT_STATUS.ACCEPTED
|
||||
];
|
||||
|
||||
const OFFER_HISTORY_PROJECT_STATUSES = [
|
||||
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
|
||||
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
|
||||
PROJECT_STATUS.QUOTE_GENERATED,
|
||||
PROJECT_STATUS.SENT,
|
||||
PROJECT_STATUS.ACCEPTED
|
||||
];
|
||||
|
||||
function buildProjectStatusEnumSql() {
|
||||
return `ENUM(${ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', ')})`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROJECT_STATUS,
|
||||
ACTIVE_WORKFLOW_PROJECT_STATUSES,
|
||||
LEGACY_PROJECT_STATUSES,
|
||||
ALL_PROJECT_STATUSES,
|
||||
QUOTE_READY_PROJECT_STATUSES,
|
||||
OFFER_HISTORY_PROJECT_STATUSES,
|
||||
buildProjectStatusEnumSql
|
||||
};
|
||||
@@ -7,10 +7,16 @@ const ProjectMaterialService = require('../services/projectMaterialService');
|
||||
const ProjectCalculationService = require('../services/projectCalculationService');
|
||||
const ProjectQuoteGenerationService = require('../services/projectQuoteGenerationService');
|
||||
const ProjectExperienceService = require('../services/projectExperienceService');
|
||||
const OrderSuggestionService = require('../services/orderSuggestionService');
|
||||
const MaterialPriceStatusService = require('../services/materialPriceStatusService');
|
||||
const bygmaScraperService = require('../services/bygmaScraperService');
|
||||
const databaseService = require('../services/databaseService');
|
||||
const openaiService = require('../services/openaiService');
|
||||
const logger = require('../utils/logger');
|
||||
const {
|
||||
PROJECT_STATUS,
|
||||
ALL_PROJECT_STATUSES
|
||||
} = require('../constants/projectStatuses');
|
||||
|
||||
// Initialize services
|
||||
const customerProjectService = new CustomerProjectService(databaseService);
|
||||
@@ -20,6 +26,8 @@ const projectMaterialService = new ProjectMaterialService(databaseService);
|
||||
const projectCalculationService = new ProjectCalculationService(databaseService);
|
||||
const projectQuoteGenerationService = new ProjectQuoteGenerationService(databaseService, openaiService);
|
||||
const projectExperienceService = new ProjectExperienceService(databaseService);
|
||||
const orderSuggestionService = new OrderSuggestionService(databaseService);
|
||||
const materialPriceStatusService = new MaterialPriceStatusService(databaseService);
|
||||
|
||||
// ==================== KUNDE PROJEKT ENDPOINTS ====================
|
||||
|
||||
@@ -118,18 +126,98 @@ router.get('/projects/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hent Ordrestyring-baserede forslag til nyt tilbud
|
||||
router.get('/projects/:id/order-suggestions', async (req, res) => {
|
||||
try {
|
||||
const projectId = parseInt(req.params.id, 10);
|
||||
|
||||
if (!projectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ugyldigt projekt ID'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await orderSuggestionService.getOrderSuggestions(projectId, {
|
||||
limit: req.query.limit
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
logger.error('Error getting order suggestions:', error);
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: status === 404 ? error.message : 'Fejl ved hentning af Ordrestyring forslag'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projects/:id/apply-suggestion', async (req, res) => {
|
||||
try {
|
||||
const projectId = parseInt(req.params.id, 10);
|
||||
const {
|
||||
suggestionSnapshot = {},
|
||||
recommendedPackage = null,
|
||||
projectDescription = null,
|
||||
inputNormalizationLog = null
|
||||
} = req.body || {};
|
||||
|
||||
if (!projectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ugyldigt projekt ID'
|
||||
});
|
||||
}
|
||||
|
||||
const selectedPackages = recommendedPackage
|
||||
? {
|
||||
id: recommendedPackage.packageId || recommendedPackage.id || null,
|
||||
name: recommendedPackage.name || null,
|
||||
source: recommendedPackage.source || 'historical_suggestion'
|
||||
}
|
||||
: null;
|
||||
|
||||
const updated = await customerProjectService.updateProjectFields(projectId, {
|
||||
project_description: projectDescription,
|
||||
selected_packages: selectedPackages,
|
||||
suggestion_snapshot: suggestionSnapshot,
|
||||
input_normalization_log: inputNormalizationLog
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Projekt ikke fundet'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await customerProjectService.getProjectWithDetails(projectId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
project: result?.project || null,
|
||||
message: 'Historisk forslag gemt på projektet'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error applying order suggestion:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved gemning af forslag'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Opdater projekt status
|
||||
router.patch('/projects/:id/status', async (req, res) => {
|
||||
try {
|
||||
const projectId = parseInt(req.params.id);
|
||||
const { status } = req.body;
|
||||
|
||||
const validStatuses = [
|
||||
'draft', 'geometry_pending', 'labor_pending', 'materials_pending',
|
||||
'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected'
|
||||
];
|
||||
|
||||
if (!validStatuses.includes(status)) {
|
||||
|
||||
if (!ALL_PROJECT_STATUSES.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ugyldig status'
|
||||
@@ -151,6 +239,23 @@ router.patch('/projects/:id/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/material-price-status', async (req, res) => {
|
||||
try {
|
||||
const status = await materialPriceStatusService.getStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...status
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching material price status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fejl ved hentning af materialepris status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== TAG GEOMETRI ENDPOINTS ====================
|
||||
|
||||
// Gem tag geometri
|
||||
@@ -196,7 +301,7 @@ router.post('/projects/:id/geometry', async (req, res) => {
|
||||
console.log('✅ [GEOMETRY] Saved successfully:', result);
|
||||
|
||||
// Opdater projekt status
|
||||
await customerProjectService.updateProjectStatus(projectId, 'labor_pending');
|
||||
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.GEOMETRY_COMPLETE);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -273,7 +378,7 @@ router.post('/projects/:id/labor', async (req, res) => {
|
||||
const result = await projectLaborService.saveProjectLabor(projectId, laborData);
|
||||
|
||||
// Opdater projekt status
|
||||
await customerProjectService.updateProjectStatus(projectId, 'materials_pending');
|
||||
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -482,7 +587,7 @@ router.post('/projects/:id/materials/bulk', async (req, res) => {
|
||||
const result = await projectMaterialService.bulkAddMaterials(projectId, materials);
|
||||
|
||||
// Opdater projekt status til calculation_ready hvis alle materialer er tilføjet
|
||||
await customerProjectService.updateProjectStatus(projectId, 'calculation_ready');
|
||||
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -682,7 +787,7 @@ router.post('/projects/:id/calculate', async (req, res) => {
|
||||
const result = await projectCalculationService.calculateProjectQuote(projectId, options);
|
||||
|
||||
// Opdater projekt status
|
||||
await customerProjectService.updateProjectStatus(projectId, 'quote_generated');
|
||||
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.REVIEW_PENDING);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -725,6 +830,48 @@ router.get('/projects/:id/calculation', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projects/:id/generate-quote-draft', async (req, res) => {
|
||||
try {
|
||||
const projectId = parseInt(req.params.id, 10);
|
||||
const {
|
||||
mode = 'ai',
|
||||
quoteData = null,
|
||||
suggestionData = null,
|
||||
customInstructions = ''
|
||||
} = req.body || {};
|
||||
|
||||
if (!projectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ugyldigt projekt ID'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await projectQuoteGenerationService.generateStructuredQuoteDraft(projectId, {
|
||||
mode,
|
||||
quoteData,
|
||||
suggestionData,
|
||||
customInstructions
|
||||
});
|
||||
|
||||
await customerProjectService.updateProjectFields(projectId, {
|
||||
input_normalization_log: result.normalizationLog || null,
|
||||
project_status: PROJECT_STATUS.REVIEW_PENDING
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error generating quote draft:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Fejl ved generering af tilbudskladde'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== HJÆLPE ENDPOINTS ====================
|
||||
|
||||
// Hent tag typer og muligheder
|
||||
@@ -1654,4 +1801,3 @@ router.get('/:projectId/with-totals', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Bootstrap and wire core backend services in one place.
|
||||
* Keeps startup wiring explicit and avoids ad-hoc requires in handlers.
|
||||
*/
|
||||
async function bootstrapServiceContainer({ logger, db, healthPool }) {
|
||||
const databaseService = require('./databaseService');
|
||||
const openaiService = require('./openaiService');
|
||||
const webPriceService = require('./webPriceService');
|
||||
|
||||
const DynamicImportService = require('./dynamicImportService');
|
||||
const OrderStatusService = require('./orderStatusService');
|
||||
const QuoteTemplateService = require('./quoteTemplateService');
|
||||
const PdfGenerationService = require('./pdfGenerationService');
|
||||
const RealDataProjectSuggestionService = require('./realDataProjectSuggestionService');
|
||||
const EnhancedOrderDataService = require('./enhancedOrderDataService');
|
||||
const MaterialPackageService = require('./materialPackageService');
|
||||
const SmartPackageManagementService = require('./smartPackageManagementService');
|
||||
const PlanningService = require('./planningService');
|
||||
const OrdrestyringSyncService = require('./ordrestyringSyncService');
|
||||
|
||||
// Shared constructors/services used in route handlers
|
||||
const PriceImportService = require('./priceImportService').PriceImportService;
|
||||
const BygmaPrisbogImportService = require('./bygmaPrisbogImportService');
|
||||
const PackageService = require('./packageService');
|
||||
const ProjectQuoteGenerationService = require('./projectQuoteGenerationService');
|
||||
const RoofGeometryService = require('./roofGeometryService');
|
||||
const BygmaImportService = require('./bygmaImportService');
|
||||
const ordrestyringService = require('./ordrestyringService');
|
||||
const graphqlClient = require('./graphqlClient');
|
||||
let LocalOrdrestyringAnalyzer = null;
|
||||
|
||||
try {
|
||||
LocalOrdrestyringAnalyzer = require('../../local_ordrestyring_analyzer');
|
||||
} catch (error) {
|
||||
logger.warn('Local Ordrestyring analyzer is unavailable:', error.message);
|
||||
}
|
||||
|
||||
const dynamicImportService = new DynamicImportService(databaseService);
|
||||
const orderStatusService = new OrderStatusService();
|
||||
const quoteTemplateService = new QuoteTemplateService();
|
||||
const pdfGenerationService = new PdfGenerationService();
|
||||
const realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService);
|
||||
const enhancedOrderDataService = new EnhancedOrderDataService(databaseService);
|
||||
const materialPackageService = new MaterialPackageService(databaseService);
|
||||
const smartPackageManagementService = new SmartPackageManagementService(databaseService);
|
||||
const planningService = new PlanningService(databaseService);
|
||||
const ordrestyringSyncService = new OrdrestyringSyncService();
|
||||
|
||||
await databaseService.initialize();
|
||||
logger.info('Database initialized successfully');
|
||||
|
||||
try {
|
||||
await openaiService.initialize();
|
||||
logger.info('OpenAI service initialized successfully');
|
||||
} catch (error) {
|
||||
logger.warn('OpenAI service initialization failed:', error.message);
|
||||
// Continue without OpenAI - fallback to static quotes only
|
||||
}
|
||||
|
||||
return {
|
||||
databaseService,
|
||||
openaiService,
|
||||
webPriceService,
|
||||
dynamicImportService,
|
||||
orderStatusService,
|
||||
quoteTemplateService,
|
||||
pdfGenerationService,
|
||||
realDataProjectSuggestionService,
|
||||
enhancedOrderDataService,
|
||||
materialPackageService,
|
||||
smartPackageManagementService,
|
||||
planningService,
|
||||
ordrestyringSyncService,
|
||||
// constructor-style deps used by handlers
|
||||
PriceImportService,
|
||||
BygmaPrisbogImportService,
|
||||
PackageService,
|
||||
ProjectQuoteGenerationService,
|
||||
RoofGeometryService,
|
||||
BygmaImportService,
|
||||
LocalOrdrestyringAnalyzer,
|
||||
ordrestyringService,
|
||||
graphqlClient,
|
||||
// infra pools
|
||||
db,
|
||||
healthPool
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootstrapServiceContainer
|
||||
};
|
||||
@@ -13,6 +13,7 @@ class CustomerProjectService {
|
||||
const {
|
||||
projectName,
|
||||
customerName,
|
||||
customerNumber,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
customerAddress,
|
||||
@@ -32,21 +33,49 @@ class CustomerProjectService {
|
||||
projectDescription
|
||||
});
|
||||
|
||||
const query = `
|
||||
INSERT INTO customer_projects (
|
||||
project_name, customer_name, customer_email,
|
||||
customer_phone, customer_address, project_description
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
let result;
|
||||
|
||||
const [result] = await this.db.pool.execute(query, [
|
||||
projectName,
|
||||
customerName,
|
||||
customerEmail || null,
|
||||
customerPhone || null,
|
||||
address || null,
|
||||
projectDescription || null
|
||||
]);
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO customer_projects (
|
||||
project_name, customer_name, customer_number, customer_email,
|
||||
customer_phone, customer_address, project_description
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
[result] = await this.db.pool.execute(query, [
|
||||
projectName,
|
||||
customerName,
|
||||
customerNumber || null,
|
||||
customerEmail || null,
|
||||
customerPhone || null,
|
||||
address || null,
|
||||
projectDescription || null
|
||||
]);
|
||||
} catch (error) {
|
||||
const isMissingCustomerNumberColumn = String(error.message || '').includes("Unknown column 'customer_number'");
|
||||
if (!isMissingCustomerNumberColumn) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn('customer_number column missing in customer_projects - using legacy insert fallback');
|
||||
|
||||
const legacyQuery = `
|
||||
INSERT INTO customer_projects (
|
||||
project_name, customer_name, customer_email,
|
||||
customer_phone, customer_address, project_description
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
[result] = await this.db.pool.execute(legacyQuery, [
|
||||
projectName,
|
||||
customerName,
|
||||
customerEmail || null,
|
||||
customerPhone || null,
|
||||
address || null,
|
||||
projectDescription || null
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('✅ Project inserted with ID:', result.insertId);
|
||||
|
||||
@@ -62,6 +91,8 @@ class CustomerProjectService {
|
||||
projectName,
|
||||
customer_name: customerName,
|
||||
customerName,
|
||||
customer_number: customerNumber || null,
|
||||
customerNumber: customerNumber || null,
|
||||
customer_email: customerEmail,
|
||||
customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
@@ -159,6 +190,61 @@ class CustomerProjectService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectFields(projectId, updates = {}) {
|
||||
try {
|
||||
const fieldMap = {
|
||||
project_name: 'project_name',
|
||||
project_description: 'project_description',
|
||||
customer_name: 'customer_name',
|
||||
customer_number: 'customer_number',
|
||||
customer_email: 'customer_email',
|
||||
customer_phone: 'customer_phone',
|
||||
customer_address: 'customer_address',
|
||||
project_status: 'project_status',
|
||||
selected_packages: 'selected_packages',
|
||||
suggestion_snapshot: 'suggestion_snapshot',
|
||||
input_normalization_log: 'input_normalization_log'
|
||||
};
|
||||
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
const column = fieldMap[key];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClauses.push(`${column} = ?`);
|
||||
values.push(
|
||||
['selected_packages', 'suggestion_snapshot', 'input_normalization_log'].includes(column) &&
|
||||
value &&
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: value
|
||||
);
|
||||
});
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
values.push(projectId);
|
||||
|
||||
const [result] = await this.db.pool.execute(
|
||||
`UPDATE customer_projects
|
||||
SET ${setClauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.affectedRows > 0;
|
||||
} catch (error) {
|
||||
logger.error('Error updating project fields:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Hent alle projekter med pagination
|
||||
async getProjects(page = 1, limit = 20, status = null) {
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const logger = require('../utils/logger');
|
||||
const { ALL_PROJECT_STATUSES } = require('../constants/projectStatuses');
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
}
|
||||
|
||||
async ensureCustomerProjectsColumn(columnName, alterSql) {
|
||||
const [columns] = await this.pool.query(
|
||||
`SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'customer_projects'
|
||||
AND COLUMN_NAME = ?`,
|
||||
[columnName]
|
||||
);
|
||||
|
||||
if (columns.length === 0) {
|
||||
await this.pool.query(alterSql);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureCustomerProjectsStatusEnum(projectStatusEnumSql) {
|
||||
const [columns] = await this.pool.query(
|
||||
`SELECT COLUMN_TYPE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'customer_projects'
|
||||
AND COLUMN_NAME = 'project_status'`
|
||||
);
|
||||
|
||||
const currentType = String(columns[0]?.COLUMN_TYPE || '').toLowerCase();
|
||||
const missingStatuses = ALL_PROJECT_STATUSES.filter(
|
||||
(status) => !currentType.includes(`'${status.toLowerCase()}'`)
|
||||
);
|
||||
|
||||
if (missingStatuses.length > 0) {
|
||||
await this.pool.query(`
|
||||
ALTER TABLE customer_projects
|
||||
MODIFY COLUMN project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// SECURITY: Require database password in environment variables
|
||||
@@ -659,25 +697,50 @@ class DatabaseService {
|
||||
`);
|
||||
|
||||
// Create customer projects system tables
|
||||
const projectStatusEnumSql = ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', ');
|
||||
|
||||
await this.pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS customer_projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_name VARCHAR(255) NOT NULL,
|
||||
customer_name VARCHAR(255) NOT NULL,
|
||||
customer_number VARCHAR(50),
|
||||
customer_email VARCHAR(255),
|
||||
customer_phone VARCHAR(50),
|
||||
customer_address TEXT,
|
||||
project_description TEXT,
|
||||
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
|
||||
selected_packages JSON,
|
||||
suggestion_snapshot JSON,
|
||||
input_normalization_log JSON,
|
||||
project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (project_status),
|
||||
INDEX idx_customer (customer_name),
|
||||
INDEX idx_customer_number (customer_number),
|
||||
INDEX idx_created (created_at)
|
||||
)
|
||||
`);
|
||||
|
||||
await this.ensureCustomerProjectsColumn(
|
||||
'customer_number',
|
||||
'ALTER TABLE customer_projects ADD COLUMN customer_number VARCHAR(50) AFTER customer_name'
|
||||
);
|
||||
await this.ensureCustomerProjectsColumn(
|
||||
'selected_packages',
|
||||
'ALTER TABLE customer_projects ADD COLUMN selected_packages JSON AFTER project_description'
|
||||
);
|
||||
await this.ensureCustomerProjectsColumn(
|
||||
'suggestion_snapshot',
|
||||
'ALTER TABLE customer_projects ADD COLUMN suggestion_snapshot JSON AFTER selected_packages'
|
||||
);
|
||||
await this.ensureCustomerProjectsColumn(
|
||||
'input_normalization_log',
|
||||
'ALTER TABLE customer_projects ADD COLUMN input_normalization_log JSON AFTER suggestion_snapshot'
|
||||
);
|
||||
await this.ensureCustomerProjectsStatusEnum(projectStatusEnumSql);
|
||||
|
||||
await this.pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS roof_geometry (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class MaterialPriceStatusService {
|
||||
constructor(databaseService) {
|
||||
this.db = databaseService;
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
const [bygma, stark] = await Promise.all([
|
||||
this.getBygmaStatus(),
|
||||
this.getStarkStatus()
|
||||
]);
|
||||
|
||||
const sources = [bygma, stark].filter(Boolean);
|
||||
const primarySource = sources
|
||||
.filter((source) => source.lastImportAt || source.latestPriceAt)
|
||||
.sort((left, right) => {
|
||||
const leftTs = new Date(left.lastImportAt || left.latestPriceAt || 0).getTime();
|
||||
const rightTs = new Date(right.lastImportAt || right.latestPriceAt || 0).getTime();
|
||||
return rightTs - leftTs;
|
||||
})[0] || null;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
primarySource,
|
||||
sources
|
||||
};
|
||||
}
|
||||
|
||||
async getBygmaStatus() {
|
||||
const latestImport = await this.safeFirst(`
|
||||
SELECT
|
||||
started_at,
|
||||
completed_at,
|
||||
status,
|
||||
imported_by,
|
||||
filename,
|
||||
updated_prices,
|
||||
successful_rows
|
||||
FROM bygma_import_log
|
||||
ORDER BY COALESCE(completed_at, started_at) DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const latestPricePoint = await this.safeFirst(`
|
||||
SELECT MAX(import_date) AS latest_price_at
|
||||
FROM bygma_price_history
|
||||
`);
|
||||
|
||||
if (!latestImport && !latestPricePoint?.latest_price_at) {
|
||||
return {
|
||||
source: 'Bygma',
|
||||
available: false,
|
||||
freshness: 'missing',
|
||||
statusLabel: 'Ingen Bygma prisliste fundet'
|
||||
};
|
||||
}
|
||||
|
||||
return this.buildSourceStatus('Bygma', latestImport, latestPricePoint?.latest_price_at);
|
||||
}
|
||||
|
||||
async getStarkStatus() {
|
||||
const latestImport = await this.safeFirst(`
|
||||
SELECT
|
||||
started_at,
|
||||
completed_at,
|
||||
status,
|
||||
imported_by,
|
||||
file_name AS filename,
|
||||
updated_items,
|
||||
total_items
|
||||
FROM import_logs
|
||||
WHERE source_system = 'Stark'
|
||||
ORDER BY COALESCE(completed_at, started_at) DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const latestPricePoint = await this.safeFirst(`
|
||||
SELECT MAX(last_updated) AS latest_price_at
|
||||
FROM stark_materials_cache
|
||||
`);
|
||||
|
||||
if (!latestImport && !latestPricePoint?.latest_price_at) {
|
||||
return {
|
||||
source: 'Stark',
|
||||
available: false,
|
||||
freshness: 'missing',
|
||||
statusLabel: 'Ingen Stark prisliste fundet'
|
||||
};
|
||||
}
|
||||
|
||||
return this.buildSourceStatus('Stark', latestImport, latestPricePoint?.latest_price_at);
|
||||
}
|
||||
|
||||
buildSourceStatus(source, latestImport, latestPriceAt) {
|
||||
const lastImportAt = latestImport?.completed_at || latestImport?.started_at || latestPriceAt || null;
|
||||
const ageDays = this.calculateAgeDays(lastImportAt);
|
||||
const freshness = this.getFreshness(ageDays);
|
||||
|
||||
return {
|
||||
source,
|
||||
available: true,
|
||||
freshness,
|
||||
statusLabel: this.getStatusLabel(source, freshness, lastImportAt),
|
||||
importStatus: latestImport?.status || 'unknown',
|
||||
lastImportAt,
|
||||
latestPriceAt: latestPriceAt || null,
|
||||
importedBy: latestImport?.imported_by || null,
|
||||
filename: latestImport?.filename || null,
|
||||
changeCount: latestImport?.updated_prices ?? latestImport?.updated_items ?? null,
|
||||
rowCount: latestImport?.successful_rows ?? latestImport?.total_items ?? null,
|
||||
ageDays
|
||||
};
|
||||
}
|
||||
|
||||
calculateAgeDays(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor((Date.now() - timestamp) / 86400000);
|
||||
}
|
||||
|
||||
getFreshness(ageDays) {
|
||||
if (ageDays === null) {
|
||||
return 'missing';
|
||||
}
|
||||
if (ageDays <= 7) {
|
||||
return 'fresh';
|
||||
}
|
||||
if (ageDays <= 30) {
|
||||
return 'aging';
|
||||
}
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
getStatusLabel(source, freshness, lastImportAt) {
|
||||
if (!lastImportAt) {
|
||||
return `Ingen ${source} prisliste fundet`;
|
||||
}
|
||||
|
||||
if (freshness === 'fresh') {
|
||||
return `${source} prisliste er opdateret`;
|
||||
}
|
||||
|
||||
if (freshness === 'aging') {
|
||||
return `${source} prisliste er ved at blive gammel`;
|
||||
}
|
||||
|
||||
return `${source} prisliste trænger til opdatering`;
|
||||
}
|
||||
|
||||
async safeFirst(query, params = []) {
|
||||
try {
|
||||
const rows = await this.db.query(query, params);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
const message = String(error.message || '').toLowerCase();
|
||||
if (
|
||||
message.includes('doesn\'t exist') ||
|
||||
message.includes('unknown table') ||
|
||||
message.includes('unknown column')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.warn('Failed to read material price status source', {
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MaterialPriceStatusService;
|
||||
@@ -0,0 +1,931 @@
|
||||
const logger = require('../utils/logger');
|
||||
const { OFFER_HISTORY_PROJECT_STATUSES } = require('../constants/projectStatuses');
|
||||
|
||||
class OrderSuggestionService {
|
||||
constructor(databaseService) {
|
||||
this.db = databaseService;
|
||||
}
|
||||
|
||||
tokenize(value) {
|
||||
return (value || '')
|
||||
.toLowerCase()
|
||||
.split(/[\s,.;:/()_-]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(token => token.length >= 3);
|
||||
}
|
||||
|
||||
normalizeText(value) {
|
||||
return (value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
parseDate(value) {
|
||||
const date = value ? new Date(value) : null;
|
||||
return Number.isNaN(date?.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
parseJson(value, fallback = null) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeRoofType(value) {
|
||||
const normalized = this.normalizeText(value)
|
||||
.replace(/æ/g, 'ae')
|
||||
.replace(/ø/g, 'oe')
|
||||
.replace(/å/g, 'aa');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalized.includes('tagpap') || normalized.includes('flad')) {
|
||||
return 'tagpap';
|
||||
}
|
||||
|
||||
if (normalized.includes('b7') || normalized.includes('eternit')) {
|
||||
return 'b7';
|
||||
}
|
||||
|
||||
if (normalized.includes('tegl') || normalized.includes('betontegl') || normalized.includes('vingetegl')) {
|
||||
return 'tegl';
|
||||
}
|
||||
|
||||
if (normalized.includes('skorsten') || normalized.includes('inddaekning')) {
|
||||
return 'skorsten';
|
||||
}
|
||||
|
||||
if (normalized.includes('velux') || normalized.includes('tagvindue')) {
|
||||
return 'velux';
|
||||
}
|
||||
|
||||
if (normalized.includes('facade') || normalized.includes('beklaedning')) {
|
||||
return 'facade';
|
||||
}
|
||||
|
||||
if (normalized.includes('sadeltag') || normalized.includes('skraat')) {
|
||||
return 'skraat_tag';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getPackageCatalog() {
|
||||
return [
|
||||
{
|
||||
packageId: 'komplet_tagrenovering',
|
||||
name: 'Komplet Tagrenovering',
|
||||
keywords: ['nyt tag', 'komplet', 'tagrenovering', 'udskiftning af tag', 'renovering af tag']
|
||||
},
|
||||
{
|
||||
packageId: 'b7_tag_udskiftning',
|
||||
name: 'B7 Tag Udskiftning - Komplet',
|
||||
keywords: ['b7', 'eternit', 'tagplader']
|
||||
},
|
||||
{
|
||||
packageId: 'tegl_tag_renovering',
|
||||
name: 'Tegltag Renovering',
|
||||
keywords: ['tegl', 'tegltag', 'betontegl', 'vingetegl', 'rygning']
|
||||
},
|
||||
{
|
||||
packageId: 'tagpap_renovering',
|
||||
name: 'Tagpap Renovering',
|
||||
keywords: ['tagpap', 'pap', 'fladt tag', 'fladtag']
|
||||
},
|
||||
{
|
||||
packageId: 'tagreparation_skorsten',
|
||||
name: 'Tagreparation Ved Skorsten',
|
||||
keywords: ['skorsten', 'inddækning', 'inddaekning', 'tagreparation', 'utæt', 'utæthed', 'kip']
|
||||
},
|
||||
{
|
||||
packageId: 'velux_tagvindue',
|
||||
name: 'Velux / Tagvindue',
|
||||
keywords: ['velux', 'tagvindue', 'ovenlys']
|
||||
},
|
||||
{
|
||||
packageId: 'facade_træ',
|
||||
name: 'Facade Træbeklædning',
|
||||
keywords: ['facade', 'beklædning', 'træbeklædning']
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
inferRecommendedPackage(project, localTopMatch, orderTopMatch) {
|
||||
const localPackages = this.parseJson(localTopMatch?.selected_packages, null);
|
||||
const selectedPackage = Array.isArray(localPackages) ? localPackages[0] : localPackages;
|
||||
if (selectedPackage?.id || selectedPackage?.name) {
|
||||
return {
|
||||
packageId: selectedPackage.id || null,
|
||||
name: selectedPackage.name || 'Historisk valgt pakke',
|
||||
source: 'historical_project',
|
||||
reason: 'Brugt på lignende lokalt projekt'
|
||||
};
|
||||
}
|
||||
|
||||
const text = [
|
||||
project.project_name,
|
||||
project.project_description,
|
||||
localTopMatch?.project_name,
|
||||
localTopMatch?.project_description,
|
||||
orderTopMatch?.description,
|
||||
orderTopMatch?.remarks
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
const inferredRoofType = this.normalizeRoofType([
|
||||
project.roof_type,
|
||||
project.project_name,
|
||||
project.project_description,
|
||||
localTopMatch?.project_description,
|
||||
orderTopMatch?.description
|
||||
].join(' '));
|
||||
|
||||
const roofTypeAliases = {
|
||||
b7: ['b7_tag_udskiftning'],
|
||||
tegl: ['tegl_tag_renovering'],
|
||||
tagpap: ['tagpap_renovering'],
|
||||
skorsten: ['tagreparation_skorsten'],
|
||||
velux: ['velux_tagvindue']
|
||||
};
|
||||
|
||||
const directRoofTypeMatch = this.getPackageCatalog().find(item =>
|
||||
roofTypeAliases[inferredRoofType]?.includes(item.packageId)
|
||||
);
|
||||
|
||||
if (directRoofTypeMatch) {
|
||||
return {
|
||||
packageId: directRoofTypeMatch.packageId,
|
||||
name: directRoofTypeMatch.name,
|
||||
source: 'inferred',
|
||||
reason: 'Udledt fra tagtype og historiske nøgleord'
|
||||
};
|
||||
}
|
||||
|
||||
const match = this.getPackageCatalog().find(item =>
|
||||
item.keywords.some(keyword => text.includes(keyword))
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
packageId: match.packageId,
|
||||
name: match.name,
|
||||
source: 'inferred',
|
||||
reason: 'Udledt fra projekttype og historiske matches'
|
||||
};
|
||||
}
|
||||
|
||||
buildSuggestionConfidence(topMatch, matchCount) {
|
||||
if (!topMatch) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
if (topMatch.sourceType === 'local_project' && topMatch.relevanceScore >= 65) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (topMatch.sameCustomer && matchCount >= 2) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (topMatch.relevanceScore >= 40) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'low';
|
||||
}
|
||||
|
||||
roundQuantity(value) {
|
||||
return Math.round((parseFloat(value) || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
scaleFactor(targetArea, sourceArea) {
|
||||
const target = parseFloat(targetArea) || 0;
|
||||
const source = parseFloat(sourceArea) || 0;
|
||||
if (!target || !source || source <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const ratio = target / source;
|
||||
return Math.min(Math.max(ratio, 0.35), 3.5);
|
||||
}
|
||||
|
||||
scaleMaterialQuantity(material, factor) {
|
||||
const unit = this.normalizeText(material.unit);
|
||||
|
||||
if (unit.includes('pakke') || unit.includes('stk')) {
|
||||
return Math.max(1, Math.round((parseFloat(material.quantity) || 0) * factor));
|
||||
}
|
||||
|
||||
return this.roundQuantity((parseFloat(material.quantity) || 0) * factor);
|
||||
}
|
||||
|
||||
normalizeMaterialDraft(material, factor = 1, reason = 'Fra historisk match') {
|
||||
return {
|
||||
name: material.name || material.material_name || 'Historisk materiale',
|
||||
quantity: this.scaleMaterialQuantity(material, factor),
|
||||
unit: material.unit || 'stk',
|
||||
unitPrice: parseFloat(material.unitPrice ?? material.unit_price ?? 0) || 0,
|
||||
category: material.category || material.material_category || 'Historik',
|
||||
calculation: reason
|
||||
};
|
||||
}
|
||||
|
||||
normalizeLaborDraft(task, factor = 1, fallbackRate = 580) {
|
||||
const totalHours = this.roundQuantity((parseFloat(task.totalHours ?? task.total_work_hours ?? task.fixedHours ?? task.hours ?? 0) || 0) * factor);
|
||||
const rate = parseFloat(task.rate ?? task.hourly_rate ?? fallbackRate) || fallbackRate;
|
||||
|
||||
return {
|
||||
name: task.name || task.task || 'Historisk opgave',
|
||||
description: task.description || task.notes || 'Udledt fra historisk tilbud',
|
||||
totalHours,
|
||||
rate,
|
||||
totalCost: this.roundQuantity(totalHours * rate),
|
||||
category: task.category || 'Historik'
|
||||
};
|
||||
}
|
||||
|
||||
buildLaborTasksFromBreakdown(laborRow) {
|
||||
if (!laborRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hourlyRate = parseFloat(laborRow.hourly_rate) || 580;
|
||||
const breakdown = this.parseJson(laborRow.work_breakdown, []);
|
||||
|
||||
if (Array.isArray(breakdown) && breakdown.length > 0) {
|
||||
return breakdown.map(item => this.normalizeLaborDraft({
|
||||
name: item.task || item.name || item.category,
|
||||
description: item.description || item.remark,
|
||||
totalHours: item.totalHours ?? item.estimatedHours ?? item.hours,
|
||||
rate: hourlyRate,
|
||||
category: 'Historik'
|
||||
}, 1, hourlyRate));
|
||||
}
|
||||
|
||||
const totalWorkHours = parseFloat(laborRow.total_work_hours) || 0;
|
||||
if (totalWorkHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [this.normalizeLaborDraft({
|
||||
name: 'Samlet arbejdsestimat',
|
||||
description: laborRow.notes || 'Opsummeret fra historisk projekt',
|
||||
totalHours: totalWorkHours,
|
||||
rate: hourlyRate,
|
||||
category: 'Historik'
|
||||
}, 1, hourlyRate)];
|
||||
}
|
||||
|
||||
summarizeHistoryContext(localTopMatch, orderTopMatch) {
|
||||
const sourceProjectIds = localTopMatch ? [localTopMatch.projectId] : [];
|
||||
const sourceCaseNumbers = orderTopMatch ? [orderTopMatch.caseNumber] : [];
|
||||
|
||||
return {
|
||||
sourceProjectIds,
|
||||
sourceCaseNumbers,
|
||||
localProjectName: localTopMatch?.projectName || null,
|
||||
orderCaseNumber: orderTopMatch?.caseNumber || null
|
||||
};
|
||||
}
|
||||
|
||||
async getProjectContext(projectId) {
|
||||
const [rows] = await this.db.pool.execute(
|
||||
`
|
||||
SELECT
|
||||
cp.id,
|
||||
cp.project_name,
|
||||
cp.project_description,
|
||||
cp.customer_name,
|
||||
cp.customer_number,
|
||||
cp.selected_packages,
|
||||
cp.suggestion_snapshot,
|
||||
rg.roof_type,
|
||||
rg.total_area,
|
||||
rg.roof_pitch
|
||||
FROM customer_projects cp
|
||||
LEFT JOIN roof_geometry rg ON rg.project_id = cp.id
|
||||
WHERE cp.id = ?
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
const error = new Error('Projekt ikke fundet');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async getLocalCandidateProjects(project, tokens, candidateLimit) {
|
||||
const params = [project.id];
|
||||
const whereClauses = [];
|
||||
|
||||
if (project.customer_number) {
|
||||
whereClauses.push('cp.customer_number = ?');
|
||||
params.push(project.customer_number);
|
||||
}
|
||||
|
||||
if (project.customer_name) {
|
||||
whereClauses.push('LOWER(COALESCE(cp.customer_name, \'\')) LIKE ?');
|
||||
params.push(`%${this.normalizeText(project.customer_name)}%`);
|
||||
}
|
||||
|
||||
if (project.roof_type) {
|
||||
whereClauses.push('LOWER(COALESCE(rg.roof_type, \'\')) = ?');
|
||||
params.push(this.normalizeText(project.roof_type));
|
||||
}
|
||||
|
||||
tokens.slice(0, 6).forEach(token => {
|
||||
whereClauses.push(
|
||||
'(LOWER(COALESCE(cp.project_name, \'\')) LIKE ? OR LOWER(COALESCE(cp.project_description, \'\')) LIKE ?)'
|
||||
);
|
||||
params.push(`%${token}%`, `%${token}%`);
|
||||
});
|
||||
|
||||
if (whereClauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [rows] = await this.db.pool.execute(
|
||||
`
|
||||
SELECT
|
||||
cp.id AS project_id,
|
||||
cp.project_name,
|
||||
cp.project_description,
|
||||
cp.customer_name,
|
||||
cp.customer_number,
|
||||
cp.project_status,
|
||||
cp.selected_packages,
|
||||
cp.created_at,
|
||||
cp.updated_at,
|
||||
rg.roof_type,
|
||||
rg.total_area,
|
||||
rg.roof_pitch,
|
||||
pl.total_work_hours,
|
||||
pl.hourly_rate,
|
||||
pl.work_breakdown,
|
||||
pl.notes AS labor_notes
|
||||
FROM customer_projects cp
|
||||
LEFT JOIN roof_geometry rg ON rg.project_id = cp.id
|
||||
LEFT JOIN project_labor pl ON pl.project_id = cp.id
|
||||
WHERE cp.id <> ? AND (${whereClauses.join(' OR ')})
|
||||
ORDER BY cp.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[...params, candidateLimit]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectIds = rows.map(row => row.project_id);
|
||||
const placeholders = projectIds.map(() => '?').join(', ');
|
||||
const [materials] = await this.db.pool.execute(
|
||||
`
|
||||
SELECT
|
||||
project_id,
|
||||
material_name,
|
||||
material_category,
|
||||
quantity,
|
||||
unit,
|
||||
unit_price
|
||||
FROM project_materials
|
||||
WHERE project_id IN (${placeholders})
|
||||
ORDER BY project_id, total_price DESC, created_at DESC
|
||||
`,
|
||||
projectIds
|
||||
);
|
||||
|
||||
const [quotes] = await this.db.pool.execute(
|
||||
`
|
||||
SELECT project_id, quote_text, created_at
|
||||
FROM generated_quotes
|
||||
WHERE project_id IN (${placeholders})
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
projectIds
|
||||
);
|
||||
|
||||
const materialsByProject = new Map();
|
||||
materials.forEach(material => {
|
||||
if (!materialsByProject.has(material.project_id)) {
|
||||
materialsByProject.set(material.project_id, []);
|
||||
}
|
||||
materialsByProject.get(material.project_id).push(material);
|
||||
});
|
||||
|
||||
const quoteByProject = new Map();
|
||||
quotes.forEach(quote => {
|
||||
if (!quoteByProject.has(quote.project_id)) {
|
||||
quoteByProject.set(quote.project_id, quote);
|
||||
}
|
||||
});
|
||||
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
materials: materialsByProject.get(row.project_id) || [],
|
||||
quote_text: quoteByProject.get(row.project_id)?.quote_text || '',
|
||||
latest_quote_at: quoteByProject.get(row.project_id)?.created_at || null,
|
||||
laborTasks: this.buildLaborTasksFromBreakdown(row)
|
||||
}));
|
||||
}
|
||||
|
||||
calculateLocalCandidateScore(project, candidate, tokens) {
|
||||
const projectText = [
|
||||
candidate.project_name,
|
||||
candidate.project_description,
|
||||
candidate.quote_text
|
||||
].join(' ').toLowerCase();
|
||||
const materialText = (candidate.materials || [])
|
||||
.map(material => `${material.material_name || ''} ${material.material_category || ''}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const sourceRoofType = this.normalizeRoofType(candidate.roof_type);
|
||||
const targetRoofType = this.normalizeRoofType(project.roof_type);
|
||||
const sameCustomer = Boolean(
|
||||
project.customer_number &&
|
||||
candidate.customer_number &&
|
||||
project.customer_number === candidate.customer_number
|
||||
);
|
||||
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
|
||||
if (sameCustomer) {
|
||||
score += 35;
|
||||
reasons.push('Samme kunde i lokal historik');
|
||||
} else if (
|
||||
this.normalizeText(project.customer_name) &&
|
||||
this.normalizeText(candidate.customer_name).includes(this.normalizeText(project.customer_name))
|
||||
) {
|
||||
score += 12;
|
||||
reasons.push('Kundenavn matcher');
|
||||
}
|
||||
|
||||
if (targetRoofType && sourceRoofType && targetRoofType === sourceRoofType) {
|
||||
score += 24;
|
||||
reasons.push('Samme tagtype');
|
||||
}
|
||||
|
||||
const targetArea = parseFloat(project.total_area) || 0;
|
||||
const sourceArea = parseFloat(candidate.total_area) || 0;
|
||||
if (targetArea > 0 && sourceArea > 0) {
|
||||
const delta = Math.abs(targetArea - sourceArea) / Math.max(targetArea, 1);
|
||||
if (delta <= 0.2) {
|
||||
score += 18;
|
||||
reasons.push('Lignende areal');
|
||||
} else if (delta <= 0.4) {
|
||||
score += 8;
|
||||
}
|
||||
}
|
||||
|
||||
let keywordHits = 0;
|
||||
tokens.forEach(token => {
|
||||
if (projectText.includes(token)) {
|
||||
score += 6;
|
||||
keywordHits += 1;
|
||||
}
|
||||
if (materialText.includes(token)) {
|
||||
score += 4;
|
||||
}
|
||||
});
|
||||
|
||||
if (keywordHits > 0) {
|
||||
reasons.push(`${keywordHits} nøgleord matcher`);
|
||||
}
|
||||
|
||||
if ((candidate.materials || []).length > 0) {
|
||||
score += 10;
|
||||
reasons.push('Har materialehistorik');
|
||||
}
|
||||
|
||||
if ((candidate.laborTasks || []).length > 0 || (parseFloat(candidate.total_work_hours) || 0) > 0) {
|
||||
score += 10;
|
||||
reasons.push('Har timehistorik');
|
||||
}
|
||||
|
||||
if (OFFER_HISTORY_PROJECT_STATUSES.includes(candidate.project_status)) {
|
||||
score += 8;
|
||||
reasons.push('Projektet blev brugt som tilbud');
|
||||
}
|
||||
|
||||
const latestActivity = this.parseDate(candidate.latest_quote_at || candidate.updated_at || candidate.created_at);
|
||||
if (latestActivity) {
|
||||
const ageDays = Math.floor((Date.now() - latestActivity.getTime()) / 86400000);
|
||||
if (ageDays <= 180) {
|
||||
score += 8;
|
||||
reasons.push('Nyere projekt');
|
||||
} else if (ageDays <= 365) {
|
||||
score += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sameCustomer,
|
||||
relevanceScore: score,
|
||||
matchReasons: reasons
|
||||
};
|
||||
}
|
||||
|
||||
async getOrderCandidateCases(project, tokens, candidateLimit) {
|
||||
const whereClauses = [];
|
||||
const params = [];
|
||||
|
||||
if (project.customer_number) {
|
||||
whereClauses.push('c.customer_number = ?');
|
||||
params.push(project.customer_number);
|
||||
}
|
||||
|
||||
if (project.customer_name) {
|
||||
whereClauses.push('LOWER(COALESCE(d.customer_name, \'\')) LIKE ?');
|
||||
params.push(`%${this.normalizeText(project.customer_name)}%`);
|
||||
}
|
||||
|
||||
tokens.slice(0, 6).forEach(token => {
|
||||
whereClauses.push(
|
||||
'(LOWER(COALESCE(c.description, \'\')) LIKE ? OR LOWER(COALESCE(c.remarks, \'\')) LIKE ?)'
|
||||
);
|
||||
params.push(`%${token}%`, `%${token}%`);
|
||||
});
|
||||
|
||||
if (whereClauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [rows] = await this.db.pool.execute(
|
||||
`
|
||||
SELECT
|
||||
c.case_number,
|
||||
c.customer_number,
|
||||
d.customer_name,
|
||||
c.description,
|
||||
c.remarks,
|
||||
c.creation_date,
|
||||
c.status,
|
||||
COALESCE(SUM(h.duration_hours), 0) AS total_hours,
|
||||
COUNT(h.id) AS hour_entries
|
||||
FROM ordrestyring_local.cases c
|
||||
LEFT JOIN ordrestyring_local.debtors d
|
||||
ON d.customer_number = c.customer_number
|
||||
LEFT JOIN ordrestyring_local.hours h
|
||||
ON h.case_number = c.case_number
|
||||
WHERE ${whereClauses.join(' OR ')}
|
||||
GROUP BY
|
||||
c.case_number,
|
||||
c.customer_number,
|
||||
d.customer_name,
|
||||
c.description,
|
||||
c.remarks,
|
||||
c.creation_date,
|
||||
c.status
|
||||
ORDER BY c.creation_date DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[...params, candidateLimit]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
calculateOrderCandidateScore(project, candidate, tokens) {
|
||||
const descriptionText = this.normalizeText(candidate.description);
|
||||
const remarksText = this.normalizeText(candidate.remarks);
|
||||
const combinedText = `${descriptionText} ${remarksText}`.trim();
|
||||
const customerName = this.normalizeText(candidate.customer_name);
|
||||
const projectCustomerName = this.normalizeText(project.customer_name);
|
||||
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
|
||||
const sameCustomer = Boolean(
|
||||
project.customer_number &&
|
||||
candidate.customer_number &&
|
||||
project.customer_number === candidate.customer_number
|
||||
);
|
||||
|
||||
if (sameCustomer) {
|
||||
score += 50;
|
||||
reasons.push('Samme kunde i Ordrestyring');
|
||||
} else if (projectCustomerName && customerName && customerName.includes(projectCustomerName)) {
|
||||
score += 15;
|
||||
reasons.push('Kundenavn matcher');
|
||||
}
|
||||
|
||||
let keywordHits = 0;
|
||||
tokens.forEach(token => {
|
||||
if (combinedText.includes(token)) {
|
||||
score += 8;
|
||||
keywordHits += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (keywordHits > 0) {
|
||||
reasons.push(`${keywordHits} nøgleord matcher`);
|
||||
}
|
||||
|
||||
const creationDate = this.parseDate(candidate.creation_date);
|
||||
if (creationDate) {
|
||||
const ageDays = Math.floor((Date.now() - creationDate.getTime()) / 86400000);
|
||||
if (ageDays <= 180) {
|
||||
score += 10;
|
||||
reasons.push('Nyere sag');
|
||||
} else if (ageDays <= 365) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
|
||||
if ((parseFloat(candidate.total_hours) || 0) > 0) {
|
||||
score += 8;
|
||||
reasons.push('Har timehistorik');
|
||||
}
|
||||
|
||||
return {
|
||||
sameCustomer,
|
||||
relevanceScore: score,
|
||||
matchReasons: reasons
|
||||
};
|
||||
}
|
||||
|
||||
buildSuggestedDescription(project, localTopMatch, orderTopMatch) {
|
||||
const currentDescription = (project.project_description || '').trim();
|
||||
if (currentDescription) {
|
||||
return currentDescription;
|
||||
}
|
||||
|
||||
if (localTopMatch?.project_description) {
|
||||
return localTopMatch.project_description.trim();
|
||||
}
|
||||
|
||||
if (orderTopMatch?.description) {
|
||||
return orderTopMatch.description.trim();
|
||||
}
|
||||
|
||||
return `Tilbud på ${project.project_name || 'projekt'}`;
|
||||
}
|
||||
|
||||
buildSuggestionSummary(localMatches, orderMatches, topMatch) {
|
||||
if (!topMatch) {
|
||||
return 'Ingen lokale historiske sager matcher godt nok endnu.';
|
||||
}
|
||||
|
||||
const localCount = localMatches.length;
|
||||
const orderCount = orderMatches.length;
|
||||
const pieces = [];
|
||||
|
||||
if (localCount > 0) {
|
||||
pieces.push(`${localCount} lokale projekter`);
|
||||
}
|
||||
|
||||
if (orderCount > 0) {
|
||||
pieces.push(`${orderCount} Ordrestyring-sager`);
|
||||
}
|
||||
|
||||
const customerPart = topMatch.sameCustomer ? ' fra samme kunde' : '';
|
||||
return `Baseret på ${pieces.join(' og ')}${customerPart}.`;
|
||||
}
|
||||
|
||||
buildClarifications(project, recommendedPackage, materialsDraft, laborDraft) {
|
||||
const clarifications = [];
|
||||
|
||||
if (!project.roof_type) {
|
||||
clarifications.push('Vælg tagtype for mere præcis pakkeanbefaling.');
|
||||
}
|
||||
|
||||
if (!(parseFloat(project.total_area) || 0)) {
|
||||
clarifications.push('Indtast tagmål eller areal for bedre skalering af materialer og timer.');
|
||||
}
|
||||
|
||||
if (!recommendedPackage) {
|
||||
clarifications.push('Vælg en Smart Pakke manuelt hvis ingen historik matcher tydeligt.');
|
||||
}
|
||||
|
||||
if (materialsDraft.length === 0) {
|
||||
clarifications.push('Tilføj eller bekræft materialer før tilbuddet sendes.');
|
||||
}
|
||||
|
||||
if (laborDraft.length === 0) {
|
||||
clarifications.push('Tilføj eller bekræft timer/opgaver før tilbuddet sendes.');
|
||||
}
|
||||
|
||||
return clarifications.slice(0, 3);
|
||||
}
|
||||
|
||||
buildWarnings(topMatch, materialsDraft, laborDraft) {
|
||||
const warnings = [];
|
||||
|
||||
if (!topMatch) {
|
||||
warnings.push('Ingen stærk historisk reference fundet endnu.');
|
||||
}
|
||||
|
||||
if (topMatch?.sourceType === 'orderstyring_case' && materialsDraft.length === 0) {
|
||||
warnings.push('Ordrestyring-match giver primært tekst og timer, ikke komplet materialeliste.');
|
||||
}
|
||||
|
||||
if (laborDraft.length === 0) {
|
||||
warnings.push('Timer er ikke udfyldt automatisk og bør dobbelttjekkes.');
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
mapMatch(match) {
|
||||
if (match.sourceType === 'local_project') {
|
||||
return {
|
||||
sourceType: 'local_project',
|
||||
projectId: match.projectId,
|
||||
projectName: match.projectName,
|
||||
customerName: match.customerName || '',
|
||||
description: match.projectDescription || '',
|
||||
creationDate: match.creationDate || null,
|
||||
totalHours: this.roundQuantity(match.totalHours),
|
||||
sameCustomer: match.sameCustomer,
|
||||
relevanceScore: match.relevanceScore,
|
||||
matchReasons: match.matchReasons
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceType: 'orderstyring_case',
|
||||
caseNumber: match.caseNumber,
|
||||
customerNumber: match.customerNumber,
|
||||
customerName: match.customerName || '',
|
||||
description: match.description || '',
|
||||
remarks: match.remarks || '',
|
||||
status: match.status || '',
|
||||
creationDate: match.creationDate || null,
|
||||
totalHours: this.roundQuantity(match.totalHours),
|
||||
hourEntries: parseInt(match.hourEntries, 10) || 0,
|
||||
sameCustomer: match.sameCustomer,
|
||||
relevanceScore: match.relevanceScore,
|
||||
matchReasons: match.matchReasons
|
||||
};
|
||||
}
|
||||
|
||||
async getOrderSuggestions(projectId, options = {}) {
|
||||
const limit = Math.min(parseInt(options.limit, 10) || 5, 10);
|
||||
const candidateLimit = Math.max(limit * 4, 12);
|
||||
|
||||
try {
|
||||
const project = await this.getProjectContext(projectId);
|
||||
const tokens = this.tokenize(
|
||||
`${project.project_name || ''} ${project.project_description || ''} ${project.roof_type || ''}`
|
||||
);
|
||||
|
||||
const [localCandidates, orderCandidates] = await Promise.all([
|
||||
this.getLocalCandidateProjects(project, tokens, candidateLimit),
|
||||
this.getOrderCandidateCases(project, tokens, candidateLimit)
|
||||
]);
|
||||
|
||||
const localMatches = localCandidates
|
||||
.map(candidate => {
|
||||
const scored = this.calculateLocalCandidateScore(project, candidate, tokens);
|
||||
return {
|
||||
sourceType: 'local_project',
|
||||
projectId: candidate.project_id,
|
||||
projectName: candidate.project_name || '',
|
||||
projectDescription: candidate.project_description || '',
|
||||
customerName: candidate.customer_name || '',
|
||||
customerNumber: candidate.customer_number || null,
|
||||
projectStatus: candidate.project_status || '',
|
||||
selected_packages: candidate.selected_packages || null,
|
||||
creationDate: candidate.updated_at || candidate.created_at || null,
|
||||
roofType: candidate.roof_type || '',
|
||||
totalArea: parseFloat(candidate.total_area) || 0,
|
||||
totalHours: parseFloat(candidate.total_work_hours) || 0,
|
||||
hourlyRate: parseFloat(candidate.hourly_rate) || 580,
|
||||
materials: candidate.materials || [],
|
||||
laborTasks: candidate.laborTasks || [],
|
||||
quoteText: candidate.quote_text || '',
|
||||
...scored
|
||||
};
|
||||
})
|
||||
.filter(candidate => candidate.relevanceScore > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.relevanceScore !== left.relevanceScore) {
|
||||
return right.relevanceScore - left.relevanceScore;
|
||||
}
|
||||
|
||||
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
const orderMatches = orderCandidates
|
||||
.map(candidate => ({
|
||||
sourceType: 'orderstyring_case',
|
||||
caseNumber: candidate.case_number,
|
||||
customerNumber: candidate.customer_number,
|
||||
customerName: candidate.customer_name || '',
|
||||
description: candidate.description || '',
|
||||
remarks: candidate.remarks || '',
|
||||
status: candidate.status || '',
|
||||
creationDate: candidate.creation_date || null,
|
||||
totalHours: parseFloat(candidate.total_hours) || 0,
|
||||
hourEntries: parseInt(candidate.hour_entries, 10) || 0,
|
||||
...this.calculateOrderCandidateScore(project, candidate, tokens)
|
||||
}))
|
||||
.filter(candidate => candidate.relevanceScore > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.relevanceScore !== left.relevanceScore) {
|
||||
return right.relevanceScore - left.relevanceScore;
|
||||
}
|
||||
|
||||
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
const combinedMatches = [...localMatches, ...orderMatches]
|
||||
.sort((left, right) => {
|
||||
if (right.relevanceScore !== left.relevanceScore) {
|
||||
return right.relevanceScore - left.relevanceScore;
|
||||
}
|
||||
|
||||
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
const topMatch = combinedMatches[0] || null;
|
||||
const localTopMatch = localMatches[0] || null;
|
||||
const orderTopMatch = orderMatches[0] || null;
|
||||
const projectScaleFactor = this.scaleFactor(project.total_area, localTopMatch?.totalArea);
|
||||
|
||||
const materialsDraft = localTopMatch
|
||||
? localTopMatch.materials
|
||||
.slice(0, 16)
|
||||
.map(material => this.normalizeMaterialDraft(material, projectScaleFactor, 'Skaleret fra lignende historisk projekt'))
|
||||
: [];
|
||||
|
||||
const laborDraft = localTopMatch
|
||||
? localTopMatch.laborTasks.map(task =>
|
||||
this.normalizeLaborDraft(task, projectScaleFactor, localTopMatch.hourlyRate)
|
||||
)
|
||||
: (orderTopMatch && orderTopMatch.totalHours > 0
|
||||
? [this.normalizeLaborDraft({
|
||||
name: 'Historisk arbejdsestimat',
|
||||
description: `Udledt fra Ordrestyring sag ${orderTopMatch.caseNumber}`,
|
||||
totalHours: orderTopMatch.totalHours,
|
||||
rate: 580,
|
||||
category: 'Historik'
|
||||
})]
|
||||
: []);
|
||||
|
||||
const recommendedPackage = this.inferRecommendedPackage(project, localTopMatch, orderTopMatch);
|
||||
const suggestedDescription = this.buildSuggestedDescription(project, localTopMatch, orderTopMatch);
|
||||
const averageHistoricalHours = combinedMatches.length > 0
|
||||
? combinedMatches.reduce((sum, match) => sum + (parseFloat(match.totalHours) || 0), 0) / combinedMatches.length
|
||||
: 0;
|
||||
const historyContext = this.summarizeHistoryContext(localTopMatch, orderTopMatch);
|
||||
const clarifications = this.buildClarifications(project, recommendedPackage, materialsDraft, laborDraft);
|
||||
const warnings = this.buildWarnings(topMatch, materialsDraft, laborDraft);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
customerNumber: project.customer_number || null,
|
||||
recommendation: {
|
||||
confidence: this.buildSuggestionConfidence(topMatch, combinedMatches.length),
|
||||
summary: this.buildSuggestionSummary(localMatches, orderMatches, topMatch),
|
||||
suggestedDescription,
|
||||
descriptionDraft: suggestedDescription,
|
||||
averageHistoricalHours: this.roundQuantity(averageHistoricalHours),
|
||||
recommendedPackage,
|
||||
warnings,
|
||||
clarifications
|
||||
},
|
||||
materialsDraft,
|
||||
laborDraft,
|
||||
prefill: {
|
||||
recommendedPackage,
|
||||
suggestedDescription,
|
||||
materials: materialsDraft,
|
||||
laborTasks: laborDraft,
|
||||
historyContext
|
||||
},
|
||||
historyContext,
|
||||
matches: combinedMatches.map(match => this.mapMatch(match))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error building Orderstyring suggestions', {
|
||||
projectId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OrderSuggestionService;
|
||||
+206
-1395
File diff suppressed because it is too large
Load Diff
+33
-4
@@ -6,11 +6,30 @@ Use this file to keep AI assistants (Copilot, Claude, Cursor) aligned on project
|
||||
|
||||
**Tilbudgivern** is an AI-powered quote calculator for Danish carpenters, focused on roofing work. It uses OpenAI GPT-4 to generate professional quotes with auto-calculated materials and labor.
|
||||
|
||||
## Mission Memory
|
||||
|
||||
This is the core mission and should guide product, UX, AI, and data decisions:
|
||||
|
||||
- Tilbudgivern must make it possible for a carpenter to stand with the customer, note a few roof measurements and short project notes, and get to a usable quote draft as fast as possible.
|
||||
- The primary use case is mobile phone on site or laptop in the van, so speed, low friction, and strong defaults matter more than long forms.
|
||||
- The system should reuse as much historical data as possible from both local Tilbudgivern projects and Ordrestyring history.
|
||||
- Smart Packages are the operational backbone: they should be easy to choose, easy to edit, and contain realistic carpenter tasks, hours, materials, and pricing structure.
|
||||
- AI should act as a carpenter-friendly writing and cleanup assistant: fix spelling and structure, turn rough notes into sharp customer-facing offer text, but never silently invent or change factual numbers.
|
||||
- Every quote flow should create reusable historical learning signals, so the system gets better the more it is used.
|
||||
|
||||
Short version:
|
||||
|
||||
> Fast field quote creation for carpenters, powered by reusable history, editable Smart Packages, and AI-assisted customer text.
|
||||
|
||||
## Active Focus
|
||||
|
||||
- [ ] Smart Packages testing and refinement
|
||||
- [ ] UI/UX improvements for quote creation flow
|
||||
- [ ] Ordrestyring API GraphQL migration
|
||||
- [ ] 2-minute quick quote flow for field use
|
||||
- [ ] Reuse historical data from local projects + Ordrestyring in Smart Package selection
|
||||
- [ ] Keep Smart Packages editable but prefilled from history
|
||||
- [ ] Generate structured AI quote drafts from project/package/material/labor data
|
||||
- [ ] Capture learning data from each completed quote flow
|
||||
- [ ] Persist project workflow status in DB: `geometry_complete -> smart_package_complete -> review_pending -> ready_for_ordrestyring -> sent_to_ordrestyring`
|
||||
- [ ] Show latest material pricelist freshness from persisted import logs/API in carpenter flow
|
||||
|
||||
## Tech Stack Quick Reference
|
||||
|
||||
@@ -48,6 +67,7 @@ Pre-configured roofing packages with auto-calculation:
|
||||
- [CLAUDE.md](CLAUDE.md) - Claude Code instructions
|
||||
- [docs/README.md](docs/README.md) - Documentation index
|
||||
- [docs/deployment/PM2_GUIDE.md](docs/deployment/PM2_GUIDE.md) - PM2 management
|
||||
- [docs/MISSION.md](MISSION.md) - Product mission and North Star
|
||||
|
||||
### Features
|
||||
- [docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md](docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md) - All 15 packages
|
||||
@@ -102,6 +122,15 @@ Required in `backend/.env`:
|
||||
Optional:
|
||||
- `ORDRESTYRING_API_TOKEN`, `OPENAI_ADMIN_KEY`
|
||||
|
||||
Business-owned OpenAI setup:
|
||||
- All OpenAI usage must use the shared business account configuration, not ad hoc personal/project credentials.
|
||||
- Prefer the centralized backend config helpers over direct client initialization.
|
||||
|
||||
Workflow state memory:
|
||||
- Final Review must not force sending. A carpenter must be able to leave a quote in `ready_for_ordrestyring` and return later.
|
||||
- Frontend status displays should prefer persisted DB/API state over hardcoded assumptions.
|
||||
- Material price freshness should come from persisted import logs such as Bygma/Stark import history, not from frontend-only state.
|
||||
|
||||
## How to Use with AI Assistants
|
||||
|
||||
**VS Code Copilot:**
|
||||
@@ -120,4 +149,4 @@ See .cursorrules and COPILOT_MEMORY.md for project context.
|
||||
```
|
||||
|
||||
---
|
||||
*Last updated: January 2026*
|
||||
*Last updated: 2026-03-29*
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Tilbudgivern Mission
|
||||
|
||||
Last updated: 2026-03-29
|
||||
|
||||
## North Star
|
||||
|
||||
Tilbudgivern exists to make quote creation so fast and reliable that a carpenter can stand with the customer, measure the roof, note a few project facts, and leave with a strong quote draft the same day.
|
||||
|
||||
The product should work equally well on:
|
||||
- mobile phone on site
|
||||
- laptop in the van
|
||||
- desktop in the office
|
||||
|
||||
## Core Mission
|
||||
|
||||
1. Minimize friction in the field.
|
||||
The carpenter should only need a few inputs to get started:
|
||||
- customer/address
|
||||
- project type
|
||||
- roof type/material
|
||||
- length/width/pitch or rough area
|
||||
- short customer note
|
||||
|
||||
2. Reuse as much real data as possible.
|
||||
Tilbudsgiveren should prefer real historical evidence over empty forms:
|
||||
- local Tilbudgivern project history
|
||||
- synced Ordrestyring history
|
||||
- previously used Smart Packages
|
||||
- previously accepted/sent offers
|
||||
|
||||
3. Make Smart Packages the operational backbone.
|
||||
Smart Packages should be:
|
||||
- quick to choose
|
||||
- easy to edit
|
||||
- grounded in real carpenter work
|
||||
- structured with materials, tasks, hours, and pricing
|
||||
|
||||
4. Let AI help with language, not fake facts.
|
||||
AI should:
|
||||
- fix spelling and messy notes
|
||||
- structure rough carpenter input
|
||||
- produce sharp, customer-friendly offer text
|
||||
- explain the work clearly
|
||||
|
||||
AI must not:
|
||||
- invent measurements
|
||||
- invent prices
|
||||
- silently change quantities, rates, or totals
|
||||
- fabricate work not present in project/package data
|
||||
|
||||
5. Make the system improve through usage.
|
||||
Every completed quote flow should create reusable learning signals:
|
||||
- what package was suggested
|
||||
- what package was chosen
|
||||
- what materials/tasks were changed
|
||||
- what text was accepted
|
||||
- which historical cases were used
|
||||
|
||||
The system should get better over time as more quotes are created.
|
||||
|
||||
## Product Principles
|
||||
|
||||
- Field-first beats backoffice-first.
|
||||
- Prefill beats blank state.
|
||||
- Editable beats locked automation.
|
||||
- Real history beats generic AI.
|
||||
- Short path to first useful draft beats perfect initial completeness.
|
||||
- Structured project data beats free-text prompts.
|
||||
|
||||
## What “Good” Looks Like
|
||||
|
||||
A carpenter can:
|
||||
- create a project in under 2 minutes
|
||||
- get a relevant Smart Package recommendation automatically
|
||||
- receive suggested materials and hours from similar jobs
|
||||
- generate a customer-ready quote draft from structured data
|
||||
- correct mistakes quickly before sending
|
||||
- leave the quote in a clear persisted status before sending to Ordrestyring
|
||||
|
||||
## Implementation Direction
|
||||
|
||||
When choosing features, architecture, or UX, prefer work that moves the product toward:
|
||||
|
||||
- quick quote flow
|
||||
- strong historical matching
|
||||
- local sync and reuse of Ordrestyring data
|
||||
- structured quote generation
|
||||
- mobile-friendly editing and confirmation
|
||||
- persisted workflow states that match the real carpenter journey
|
||||
- visible material pricelist freshness from synced supplier/import data
|
||||
- measurable learning from accepted/edited quotes
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Avoid optimizing primarily for:
|
||||
- long admin workflows before first quote value
|
||||
- AI-generated content without structured project grounding
|
||||
- rigid package systems that are hard for carpenters to override
|
||||
- flows that assume office-only usage
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
import './FinalReview.css';
|
||||
import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '../utils/projectStatus';
|
||||
|
||||
const FinalReview = ({
|
||||
apiBaseUrl,
|
||||
@@ -19,6 +20,9 @@ const FinalReview = ({
|
||||
// Editable pricing state
|
||||
const [editableMaterials, setEditableMaterials] = useState(packageData?.materials || []);
|
||||
const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []);
|
||||
const [editableProjectDescription, setEditableProjectDescription] = useState(
|
||||
packageData?.recommendedDescription || project?.project_description || project?.description || ''
|
||||
);
|
||||
const [materialProfitMargin, setMaterialProfitMargin] = useState(20); // 20% på materialer
|
||||
const [laborProfitMargin, setLaborProfitMargin] = useState(2); // 2% på timer
|
||||
|
||||
@@ -32,6 +36,103 @@ const FinalReview = ({
|
||||
// PDF state
|
||||
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState(null);
|
||||
const [orderSuggestions, setOrderSuggestions] = useState(null);
|
||||
const [isLoadingOrderSuggestions, setIsLoadingOrderSuggestions] = useState(false);
|
||||
const [orderSuggestionError, setOrderSuggestionError] = useState('');
|
||||
const [projectStatus, setProjectStatus] = useState(project?.project_status || PROJECT_STATUS.DRAFT);
|
||||
const [materialPriceStatus, setMaterialPriceStatus] = useState(null);
|
||||
const [isSavingReadyStatus, setIsSavingReadyStatus] = useState(false);
|
||||
|
||||
const getErrorMessage = React.useCallback((errorLike, fallback = 'Ukendt fejl') => {
|
||||
if (!errorLike) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof errorLike === 'string') {
|
||||
return errorLike;
|
||||
}
|
||||
|
||||
if (typeof errorLike.message === 'string') {
|
||||
return errorLike.message;
|
||||
}
|
||||
|
||||
if (typeof errorLike.error === 'string') {
|
||||
return errorLike.error;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(errorLike);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildLocalStaticQuote = React.useCallback((quoteData) => {
|
||||
const projectName = quoteData?.project?.project_name || quoteData?.project?.name || 'Tilbud';
|
||||
const customerName = quoteData?.project?.customer_name || 'Kunde';
|
||||
const address = quoteData?.project?.customer_address || 'Opmålt hos kunde';
|
||||
const roofType = quoteData?.geometry?.roof_type || 'Tagarbejde';
|
||||
const roofArea = quoteData?.geometry?.total_area || quoteData?.geometry?.roofArea || 0;
|
||||
const packageName = quoteData?.package?.name || '';
|
||||
const materials = Array.isArray(quoteData?.materials) ? quoteData.materials : [];
|
||||
const labor = Array.isArray(quoteData?.labor) ? quoteData.labor : [];
|
||||
const totals = quoteData?.totals || {};
|
||||
|
||||
const materialLines = materials.slice(0, 6).map(material =>
|
||||
` • ${material.name || material.material_name}: ${material.quantity} ${material.unit}`
|
||||
).join('\n');
|
||||
|
||||
const laborLines = labor.map(task =>
|
||||
` • ${task.name || task.task_name}${task.description ? ` - ${task.description}` : ''} (${(parseFloat(task.totalHours ?? task.estimated_hours) || 0).toFixed(1)} timer)`
|
||||
).join('\n');
|
||||
|
||||
return `${String(projectName).toUpperCase()}\n\nProjekt\n • Kunde: ${customerName}\n • Adresse: ${address}\n • Tagtype: ${roofType}\n • Tagareal: ${roofArea} m²${packageName ? `\n • Valgt løsning: ${packageName}` : ''}\n\nArbejdet omfatter\n${laborLines || ' • Arbejdsopgaver aftales efter endelig gennemgang'}\n\nMaterialer\n${materialLines || ' • Materialer specificeres efter endelig opmåling'}\n\nPris\n • Materialer: ${formatCurrency(totals.materials || 0)} ekskl. moms\n • Arbejde: ${formatCurrency(totals.labor || 0)} ekskl. moms\n • Subtotal: ${formatCurrency(totals.subtotal || 0)} ekskl. moms\n • Moms (25%): ${formatCurrency(totals.tax || 0)}\n • Samlet pris: ${formatCurrency(totals.total || 0)} inkl. moms`;
|
||||
}, []);
|
||||
|
||||
const applySuggestedSolution = React.useCallback(async () => {
|
||||
if (!orderSuggestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedDescription =
|
||||
orderSuggestions.prefill?.suggestedDescription ||
|
||||
orderSuggestions.recommendation?.suggestedDescription ||
|
||||
'';
|
||||
const suggestedMaterials = orderSuggestions.prefill?.materials || orderSuggestions.materialsDraft || [];
|
||||
const suggestedLaborTasks = orderSuggestions.prefill?.laborTasks || orderSuggestions.laborDraft || [];
|
||||
|
||||
if (suggestedDescription) {
|
||||
setEditableProjectDescription(suggestedDescription);
|
||||
}
|
||||
|
||||
if (suggestedMaterials.length > 0) {
|
||||
setEditableMaterials(suggestedMaterials);
|
||||
}
|
||||
|
||||
if (suggestedLaborTasks.length > 0) {
|
||||
setEditableLaborTasks(suggestedLaborTasks);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/apply-suggestion`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recommendedPackage: orderSuggestions.recommendation?.recommendedPackage || null,
|
||||
suggestionSnapshot: orderSuggestions,
|
||||
projectDescription: suggestedDescription,
|
||||
inputNormalizationLog: {
|
||||
source: 'final_review_apply_suggestion',
|
||||
appliedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not persist suggestion snapshot:', error);
|
||||
}
|
||||
}, [apiBaseUrl, orderSuggestions, project?.id]);
|
||||
|
||||
// Scroll to top when component mounts
|
||||
React.useEffect(() => {
|
||||
@@ -65,14 +166,14 @@ const FinalReview = ({
|
||||
});
|
||||
|
||||
// Normaliser geometri data til at håndtere begge navnekonventioner
|
||||
const normalizedGeometry = geometryData ? {
|
||||
const normalizedGeometry = React.useMemo(() => (geometryData ? {
|
||||
width: geometryData.width || geometryData.width_main || 0,
|
||||
length: geometryData.length || geometryData.length_main || 0,
|
||||
wallHeight: geometryData.wallHeight || geometryData.wall_height || 0,
|
||||
roofPitch: geometryData.roofPitch || geometryData.roof_pitch || 0,
|
||||
roofType: geometryData.roofType || geometryData.roof_type || 'unknown',
|
||||
totalArea: geometryData.totalArea || geometryData.total_area || 0
|
||||
} : null;
|
||||
} : null), [geometryData]);
|
||||
|
||||
console.log('📊 Normalized geometry:', normalizedGeometry);
|
||||
|
||||
@@ -84,7 +185,98 @@ const FinalReview = ({
|
||||
if (packageData?.laborTasks) {
|
||||
setEditableLaborTasks(packageData.laborTasks);
|
||||
}
|
||||
}, [packageData]);
|
||||
setEditableProjectDescription(
|
||||
packageData?.recommendedDescription || project?.project_description || project?.description || ''
|
||||
);
|
||||
}, [packageData, project]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setProjectStatus(project?.project_status || PROJECT_STATUS.DRAFT);
|
||||
}, [project?.project_status]);
|
||||
|
||||
const persistProjectStatus = React.useCallback(async (status) => {
|
||||
if (!project?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Kunne ikke opdatere projektstatus');
|
||||
}
|
||||
|
||||
setProjectStatus(status);
|
||||
return true;
|
||||
}, [apiBaseUrl, project?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadMaterialPriceStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/customer-projects/material-price-status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMaterialPriceStatus(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load material price status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadMaterialPriceStatus();
|
||||
}, [apiBaseUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!project?.id || isFinalizedProjectStatus(projectStatus) || projectStatus === PROJECT_STATUS.REVIEW_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editableMaterials.length && !editableLaborTasks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistProjectStatus(PROJECT_STATUS.REVIEW_PENDING).catch((error) => {
|
||||
console.warn('Could not persist review status:', error);
|
||||
});
|
||||
}, [editableLaborTasks.length, editableMaterials.length, persistProjectStatus, project?.id, projectStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadOrderSuggestions = async () => {
|
||||
if (!project?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingOrderSuggestions(true);
|
||||
setOrderSuggestionError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/order-suggestions`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setOrderSuggestions(data);
|
||||
} else {
|
||||
setOrderSuggestions(null);
|
||||
setOrderSuggestionError(data.error || 'Kunne ikke hente Ordrestyring forslag');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading order suggestions:', error);
|
||||
setOrderSuggestions(null);
|
||||
setOrderSuggestionError('Netværksfejl ved hentning af Ordrestyring forslag');
|
||||
} finally {
|
||||
setIsLoadingOrderSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrderSuggestions();
|
||||
}, [apiBaseUrl, project?.id]);
|
||||
|
||||
// Beregn totaler baseret på editable data
|
||||
const calculateMaterialTotal = () => {
|
||||
@@ -105,7 +297,8 @@ const FinalReview = ({
|
||||
if (onBackWithData) {
|
||||
onBackWithData({
|
||||
materials: editableMaterials,
|
||||
laborTasks: editableLaborTasks
|
||||
laborTasks: editableLaborTasks,
|
||||
recommendedDescription: editableProjectDescription
|
||||
});
|
||||
} else {
|
||||
// Fallback to regular onBack if no data handler
|
||||
@@ -123,6 +316,11 @@ const FinalReview = ({
|
||||
const taxRate = 0.25; // 25% moms
|
||||
const taxAmount = subtotalWithProfit * taxRate;
|
||||
const grandTotal = subtotalWithProfit + taxAmount;
|
||||
const projectStatusMeta = getProjectStatusMeta(projectStatus);
|
||||
const primaryMaterialSource = materialPriceStatus?.primarySource || null;
|
||||
const formattedPriceImportDate = primaryMaterialSource?.lastImportAt
|
||||
? new Date(primaryMaterialSource.lastImportAt).toLocaleString('da-DK')
|
||||
: null;
|
||||
|
||||
const handleSubmitToOrdering = async () => {
|
||||
setIsSubmitting(true);
|
||||
@@ -134,7 +332,11 @@ const FinalReview = ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
customer: project.customer_name,
|
||||
description: project.description
|
||||
customerNumber: project.customer_number,
|
||||
customerEmail: project.customer_email,
|
||||
customerPhone: project.customer_phone,
|
||||
customerAddress: project.customer_address,
|
||||
description: editableProjectDescription
|
||||
},
|
||||
geometry: {
|
||||
width: normalizedGeometry.width,
|
||||
@@ -144,7 +346,7 @@ const FinalReview = ({
|
||||
roofArea: calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
|
||||
},
|
||||
package: {
|
||||
materials: packageData.materials.map(material => ({
|
||||
materials: editableMaterials.map(material => ({
|
||||
name: material.name,
|
||||
quantity: material.quantity,
|
||||
unit: material.unit,
|
||||
@@ -152,7 +354,7 @@ const FinalReview = ({
|
||||
total: material.quantity * material.unitPrice,
|
||||
category: material.category
|
||||
})),
|
||||
laborTasks: packageData.laborTasks.map(task => ({
|
||||
laborTasks: editableLaborTasks.map(task => ({
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
area: task.area,
|
||||
@@ -193,6 +395,11 @@ const FinalReview = ({
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await persistProjectStatus(PROJECT_STATUS.SENT_TO_ORDRESTYRING);
|
||||
} catch (statusError) {
|
||||
console.warn('Could not persist sent status:', statusError);
|
||||
}
|
||||
setSubmitResult({
|
||||
success: true,
|
||||
offerNumber: result.offerNumber, // ✅ Now returns real offer number from GraphQL!
|
||||
@@ -222,6 +429,19 @@ const FinalReview = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkReadyForOrdrestyring = async () => {
|
||||
setIsSavingReadyStatus(true);
|
||||
|
||||
try {
|
||||
await persistProjectStatus(PROJECT_STATUS.READY_FOR_ORDRESTYRING);
|
||||
notify.success('Projektet er markeret som klar til Ordrestyring');
|
||||
} catch (error) {
|
||||
notify.error(getErrorMessage(error, 'Kunne ikke gemme status'));
|
||||
} finally {
|
||||
setIsSavingReadyStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRoofArea = (width, length, pitch) => {
|
||||
const pitchRad = (pitch * Math.PI) / 180;
|
||||
const roofSlope = width / (2 * Math.cos(pitchRad));
|
||||
@@ -229,61 +449,64 @@ const FinalReview = ({
|
||||
};
|
||||
|
||||
// Generer tilbudstekst (statisk eller AI)
|
||||
const handleGenerateQuote = async (type = 'static') => {
|
||||
const handleGenerateQuote = React.useCallback(async (type = 'static') => {
|
||||
setIsGeneratingQuote(true);
|
||||
const quoteData = {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.project_name || project.name,
|
||||
customer_name: project.customer_name,
|
||||
customer_number: project.customer_number,
|
||||
customer_address: project.customer_address,
|
||||
description: editableProjectDescription
|
||||
},
|
||||
geometry: normalizedGeometry,
|
||||
package: {
|
||||
...packageData,
|
||||
recommendedDescription: editableProjectDescription,
|
||||
materials: editableMaterials,
|
||||
laborTasks: editableLaborTasks
|
||||
},
|
||||
// Include ALL detailed materials
|
||||
materials: editableMaterials.map(m => ({
|
||||
material_name: m.name,
|
||||
name: m.name,
|
||||
quantity: parseFloat(m.quantity) || 0,
|
||||
unit: m.unit,
|
||||
unit_price: parseFloat(m.unitPrice) || 0,
|
||||
unitPrice: parseFloat(m.unitPrice) || 0,
|
||||
total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0),
|
||||
category: m.category
|
||||
})),
|
||||
// Include ALL detailed labor items
|
||||
labor: editableLaborTasks.map(item => ({
|
||||
task_name: item.name,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
estimated_hours: parseFloat(item.totalHours) || 0,
|
||||
totalHours: parseFloat(item.totalHours) || 0,
|
||||
hourly_rate: parseFloat(item.rate) || 0,
|
||||
rate: parseFloat(item.rate) || 0,
|
||||
total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0)
|
||||
})),
|
||||
totals: {
|
||||
materials: materialTotal,
|
||||
labor: laborTotal,
|
||||
subtotal: subtotal,
|
||||
tax: taxAmount,
|
||||
total: grandTotal,
|
||||
totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0),
|
||||
hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580
|
||||
}
|
||||
};
|
||||
try {
|
||||
const quoteData = {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.project_name || project.name,
|
||||
customer_name: project.customer_name,
|
||||
description: project.description
|
||||
},
|
||||
geometry: normalizedGeometry,
|
||||
package: packageData,
|
||||
// Include ALL detailed materials
|
||||
materials: editableMaterials.map(m => ({
|
||||
material_name: m.name,
|
||||
name: m.name,
|
||||
quantity: parseFloat(m.quantity) || 0,
|
||||
unit: m.unit,
|
||||
unit_price: parseFloat(m.unitPrice) || 0,
|
||||
unitPrice: parseFloat(m.unitPrice) || 0,
|
||||
total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0),
|
||||
category: m.category
|
||||
})),
|
||||
// Include ALL detailed labor items
|
||||
labor: editableLaborTasks.map(item => ({
|
||||
task_name: item.name,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
estimated_hours: parseFloat(item.totalHours) || 0,
|
||||
totalHours: parseFloat(item.totalHours) || 0,
|
||||
hourly_rate: parseFloat(item.rate) || 0,
|
||||
rate: parseFloat(item.rate) || 0,
|
||||
total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0)
|
||||
})),
|
||||
totals: {
|
||||
materials: materialTotal,
|
||||
labor: laborTotal,
|
||||
subtotal: subtotal,
|
||||
tax: taxAmount,
|
||||
total: grandTotal,
|
||||
totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0),
|
||||
hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580
|
||||
}
|
||||
};
|
||||
|
||||
const endpoint = type === 'ai'
|
||||
? '/api/quotes/generate-ai'
|
||||
: '/api/quotes/generate-static';
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/generate-quote-draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: project.id,
|
||||
mode: type,
|
||||
quoteData: quoteData,
|
||||
suggestionData: orderSuggestions,
|
||||
customInstructions: aiInstructions
|
||||
})
|
||||
});
|
||||
@@ -296,44 +519,29 @@ const FinalReview = ({
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || `Server fejl: ${response.status}`);
|
||||
throw new Error(getErrorMessage(result, `Server fejl: ${response.status}`));
|
||||
}
|
||||
|
||||
// Handle async polling pattern (AI jobs take longer than Nginx 30s timeout)
|
||||
if (result.polling && result.jobId) {
|
||||
const jobId = result.jobId;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 80; // 80 × 3s = 4 minutes max
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
attempts++;
|
||||
const pollRes = await fetch(`${apiBaseUrl}/api/quotes/status/${jobId}`);
|
||||
if (!pollRes.ok) continue;
|
||||
const pollData = await pollRes.json();
|
||||
if (pollData.status === 'done') {
|
||||
setQuoteText(pollData.quoteText);
|
||||
setShowQuoteEditor(true);
|
||||
break;
|
||||
} else if (pollData.status === 'error') {
|
||||
throw new Error(pollData.error || 'AI generering fejlede');
|
||||
}
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('AI generering tog for lang tid. Prøv igen.');
|
||||
}
|
||||
} else if (result.success) {
|
||||
if (result.success) {
|
||||
setQuoteText(result.quoteText);
|
||||
setShowQuoteEditor(true);
|
||||
} else {
|
||||
notify.error('Fejl ved generering af tilbudstekst: ' + (result.error || 'Ukendt fejl'));
|
||||
throw new Error(getErrorMessage(result, 'Ukendt fejl'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating quote:', error);
|
||||
notify.error('Fejl ved generering af tilbudstekst: ' + error.message);
|
||||
if (type === 'static') {
|
||||
const fallbackQuote = buildLocalStaticQuote(quoteData);
|
||||
setQuoteText(fallbackQuote);
|
||||
setShowQuoteEditor(true);
|
||||
notify.warning(`Backend-fejl ved standard tekst. Viser lokal kladde i stedet: ${getErrorMessage(error)}`);
|
||||
} else {
|
||||
notify.error('Fejl ved generering af tilbudstekst: ' + getErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setIsGeneratingQuote(false);
|
||||
}
|
||||
};
|
||||
}, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableLaborTasks, materialTotal, laborTotal, subtotal, taxAmount, grandTotal, orderSuggestions, aiInstructions, getErrorMessage, buildLocalStaticQuote, notify]);
|
||||
|
||||
// Generer og download PDF
|
||||
const handleGeneratePDF = async () => {
|
||||
@@ -347,7 +555,7 @@ const FinalReview = ({
|
||||
name: project.name,
|
||||
project_name: project.name,
|
||||
customer_name: project.customer_name,
|
||||
description: project.description
|
||||
description: editableProjectDescription
|
||||
},
|
||||
geometry: {
|
||||
width: normalizedGeometry.width,
|
||||
@@ -504,7 +712,32 @@ const FinalReview = ({
|
||||
<div className="final-review">
|
||||
<div className="review-header">
|
||||
<h2>🎯 Final Review - Tilbud Oversigt</h2>
|
||||
<p>Gennemgå alle detaljer før indsendelse til ordrestyring</p>
|
||||
<p>Gennemgå, finpuds og gem tilbuddet før du eventuelt sender det til Ordrestyring</p>
|
||||
<div style={{display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '12px'}}>
|
||||
<span style={{
|
||||
background: projectStatusMeta.color,
|
||||
color: '#fff',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
Status: {projectStatusMeta.text}
|
||||
</span>
|
||||
{primaryMaterialSource && (
|
||||
<span style={{
|
||||
background: '#ecfeff',
|
||||
color: '#155e75',
|
||||
border: '1px solid #a5f3fc',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{primaryMaterialSource.statusLabel}{formattedPriceImportDate ? ` · ${formattedPriceImportDate}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-sections">
|
||||
@@ -514,12 +747,119 @@ const FinalReview = ({
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Projekt:</label>
|
||||
<span>{project?.name || 'Ikke defineret'}</span>
|
||||
<span>{project?.project_name || project?.name || 'Ikke defineret'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Kunde:</label>
|
||||
<span>{project?.customer_name || 'Ikke defineret'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Kundenr.:</label>
|
||||
<span>{project?.customer_number || 'Ikke koblet til Ordrestyring kunde endnu'}</span>
|
||||
</div>
|
||||
<div className="info-item" style={{gridColumn: '1 / -1'}}>
|
||||
<label>Ordrestyring erfaring:</label>
|
||||
<div style={{
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
{isLoadingOrderSuggestions && (
|
||||
<div>Henter lokale Ordrestyring forslag...</div>
|
||||
)}
|
||||
|
||||
{!isLoadingOrderSuggestions && orderSuggestionError && (
|
||||
<div style={{color: '#b91c1c'}}>{orderSuggestionError}</div>
|
||||
)}
|
||||
|
||||
{!isLoadingOrderSuggestions && !orderSuggestionError && orderSuggestions?.recommendation && (
|
||||
<>
|
||||
<div style={{marginBottom: '10px'}}>
|
||||
<strong>{orderSuggestions.recommendation.summary}</strong>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '10px', fontSize: '13px'}}>
|
||||
<span>Confidence: <strong>{orderSuggestions.recommendation.confidence}</strong></span>
|
||||
<span>Historiske timer: <strong>{orderSuggestions.recommendation.averageHistoricalHours || 0}</strong></span>
|
||||
<span>Matches: <strong>{orderSuggestions.matches?.length || 0}</strong></span>
|
||||
</div>
|
||||
{orderSuggestions.recommendation.recommendedPackage && (
|
||||
<div style={{marginBottom: '10px'}}>
|
||||
<div style={{fontSize: '13px', color: '#475569', marginBottom: '6px'}}>Anbefalet Smart Pakke</div>
|
||||
<strong>{orderSuggestions.recommendation.recommendedPackage.name}</strong>
|
||||
{orderSuggestions.recommendation.recommendedPackage.reason && (
|
||||
<div style={{fontSize: '13px', color: '#64748b', marginTop: '4px'}}>
|
||||
{orderSuggestions.recommendation.recommendedPackage.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(orderSuggestions.materialsDraft?.length > 0 || orderSuggestions.laborDraft?.length > 0) && (
|
||||
<div style={{display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '10px', fontSize: '13px'}}>
|
||||
<span>Materialer klar: <strong>{orderSuggestions.materialsDraft?.length || 0}</strong></span>
|
||||
<span>Opgaver klar: <strong>{orderSuggestions.laborDraft?.length || 0}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
{orderSuggestions.recommendation.suggestedDescription && (
|
||||
<div style={{marginBottom: '10px'}}>
|
||||
<div style={{fontSize: '13px', color: '#475569', marginBottom: '6px'}}>Foreslået kundebeskrivelse</div>
|
||||
<div style={{whiteSpace: 'pre-wrap', lineHeight: '1.5'}}>{orderSuggestions.recommendation.suggestedDescription}</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: orderSuggestions.matches?.length ? '10px' : '0'}}>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn secondary"
|
||||
onClick={applySuggestedSolution}
|
||||
>
|
||||
Brug anbefalet løsning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn secondary"
|
||||
onClick={() => setEditableProjectDescription(orderSuggestions.recommendation.suggestedDescription || '')}
|
||||
>
|
||||
Brug foreslået beskrivelse
|
||||
</button>
|
||||
</div>
|
||||
{orderSuggestions.matches?.length > 0 && (
|
||||
<div style={{display: 'grid', gap: '8px'}}>
|
||||
{orderSuggestions.matches.slice(0, 3).map((match) => (
|
||||
<div
|
||||
key={match.caseNumber}
|
||||
style={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
paddingTop: '8px',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
<strong>Sag {match.caseNumber}</strong> · {match.customerName || 'Ukendt kunde'} · {match.totalHours || 0} timer
|
||||
<div style={{marginTop: '4px'}}>{match.description || 'Ingen beskrivelse'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item" style={{gridColumn: '1 / -1'}}>
|
||||
<label>Beskrivelse til kunden:</label>
|
||||
<textarea
|
||||
value={editableProjectDescription}
|
||||
onChange={(e) => setEditableProjectDescription(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '110px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Beskrivelse:</label>
|
||||
<span>{project?.description || 'Ingen beskrivelse'}</span>
|
||||
@@ -867,11 +1207,10 @@ const FinalReview = ({
|
||||
|
||||
{/* Ordrestyring Integration */}
|
||||
<div className="review-section ordering-section">
|
||||
<h3>🚀 Send til Ordrestyring</h3>
|
||||
<h3>🚀 Ordrestyring Status</h3>
|
||||
<div className="ordering-info">
|
||||
<p>
|
||||
Når du klikker "Send til Ordrestyring", bliver tilbuddet automatisk overført til ordrestyring systemet
|
||||
hvor det kan spores og administreres.
|
||||
Du kan gemme tilbuddet som klart til Ordrestyring uden at sende det endnu. Send først, når tilbudet er helt klar.
|
||||
</p>
|
||||
|
||||
<button
|
||||
@@ -883,7 +1222,7 @@ const FinalReview = ({
|
||||
|
||||
{showOrderingDetails && (
|
||||
<div className="ordering-details">
|
||||
<h4>Data som sendes til ordrestyring:</h4>
|
||||
<h4>Data som sendes til Ordrestyring:</h4>
|
||||
<ul>
|
||||
<li>Projekt information og kunde data</li>
|
||||
<li>Geometri mål og beregninger</li>
|
||||
@@ -923,6 +1262,14 @@ const FinalReview = ({
|
||||
>
|
||||
← Tilbage til Smart Pakke
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleMarkReadyForOrdrestyring}
|
||||
className="generate-quote-btn"
|
||||
disabled={isSubmitting || isSavingReadyStatus || !packageData?.materials?.length || !packageData?.laborTasks?.length}
|
||||
>
|
||||
{isSavingReadyStatus ? 'Gemmer status...' : '💾 Klar til Ordrestyring'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitToOrdering}
|
||||
@@ -937,4 +1284,4 @@ const FinalReview = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FinalReview;
|
||||
export default FinalReview;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -631,7 +631,7 @@ const MaterialsManager = ({ apiBaseUrl, project, geometry, existingMaterials, on
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project_status: 'calculation_ready'
|
||||
project_status: 'smart_package_complete'
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import FormField from './FormField';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import AutosaveIndicator from './AutosaveIndicator';
|
||||
import useAutosave from '../hooks/useAutosave';
|
||||
import { getProjectStatusMeta } from '../utils/projectStatus';
|
||||
import './FormField.css';
|
||||
import './LoadingSpinner.css';
|
||||
import './AutosaveIndicator.css';
|
||||
@@ -16,6 +17,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const [newProjectForm, setNewProjectForm] = useState({
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
customerNumber: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
projectAddress: '',
|
||||
@@ -26,6 +28,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const [editingForm, setEditingForm] = useState({
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
customerNumber: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
projectAddress: '',
|
||||
@@ -149,6 +152,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
|
||||
const customerData = {
|
||||
customerName: customer.name,
|
||||
customerNumber: customer.customerNumber || '',
|
||||
customerEmail: customer.email || '',
|
||||
customerPhone: customer.phone || customer.mobile || '',
|
||||
projectAddress: customer.address ?
|
||||
@@ -178,6 +182,21 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
} else {
|
||||
// Customer cleared
|
||||
setSelectedCustomer(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
customerNumber: ''
|
||||
}));
|
||||
if (activeTab === 'editing' && editingProject) {
|
||||
setEditingForm(prev => ({
|
||||
...prev,
|
||||
customerNumber: ''
|
||||
}));
|
||||
} else if (activeTab === 'new') {
|
||||
setNewProjectForm(prev => ({
|
||||
...prev,
|
||||
customerNumber: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,6 +292,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const emptyForm = {
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
customerNumber: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
projectAddress: '',
|
||||
@@ -306,17 +326,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusMap = {
|
||||
'draft': { text: 'Kladde', color: '#6b7280' },
|
||||
'created': { text: 'Oprettet', color: '#3b82f6' },
|
||||
'geometry_complete': { text: 'Geometri færdig', color: '#10b981' },
|
||||
'labor_complete': { text: 'Timer færdig', color: '#f59e0b' },
|
||||
'materials_complete': { text: 'Materialer færdig', color: '#8b5cf6' },
|
||||
'calculated': { text: 'Beregnet', color: '#ef4444' },
|
||||
'quote_generated': { text: 'Tilbud klar', color: '#059669' }
|
||||
};
|
||||
|
||||
const statusInfo = statusMap[status] || { text: status, color: '#6b7280' };
|
||||
const statusInfo = getProjectStatusMeta(status);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -359,6 +369,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const projectData = {
|
||||
projectName: project.project_name,
|
||||
customerName: project.customer_name,
|
||||
customerNumber: project.customer_number || '',
|
||||
customerEmail: project.customer_email || '',
|
||||
customerPhone: project.customer_phone || '',
|
||||
projectAddress: project.customer_address || project.project_address || '',
|
||||
@@ -412,6 +423,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const emptyForm = {
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
customerNumber: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
projectAddress: '',
|
||||
@@ -704,6 +716,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
|
||||
const emptyForm = {
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
customerNumber: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
projectAddress: '',
|
||||
|
||||
@@ -5,11 +5,8 @@ import EnhancedGeometry from './EnhancedGeometry';
|
||||
import InlineSmartPackage from './InlineSmartPackage';
|
||||
import FinalReview from './FinalReview';
|
||||
import AdvancedDashboard from './AdvancedDashboard';
|
||||
import GeometryInput from './GeometryInput';
|
||||
import LaborInput from './LaborInput';
|
||||
import MaterialsManager from './MaterialsManager';
|
||||
import CalculationView from './CalculationView';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
import { getProjectStepFromStatus } from '../utils/projectStatus';
|
||||
|
||||
const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
const notify = useNotification();
|
||||
@@ -23,8 +20,7 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
const [calculation, setCalculation] = useState(null);
|
||||
const [selectedPackages, setSelectedPackages] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
||||
const [, setSelectedProjectId] = useState(null);
|
||||
const [isLoadingProjectData, setIsLoadingProjectData] = useState(false);
|
||||
|
||||
// Session storage key for persistence
|
||||
@@ -78,6 +74,17 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
{ id: 4, name: 'Final Review', icon: '🎯', description: 'Gennemgang & Tilbud' }
|
||||
];
|
||||
|
||||
const persistedStep = getProjectStepFromStatus(project?.project_status);
|
||||
const completedStepCount = Math.max(
|
||||
[
|
||||
!!project ? 1 : 0,
|
||||
!!(geometry || enhancedGeometry) ? 2 : 0,
|
||||
!!packageData ? 3 : 0,
|
||||
!!(project && enhancedGeometry && packageData) ? 4 : 0
|
||||
].reduce((maxStep, currentValue) => Math.max(maxStep, currentValue), 0),
|
||||
persistedStep
|
||||
);
|
||||
|
||||
// Load existing projects on mount
|
||||
useEffect(() => {
|
||||
const loadProjectsAsync = async () => {
|
||||
@@ -139,12 +146,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
// After loading data, determine which step to start at
|
||||
// Priority: If we have geometry, allow access to Smart Pakke (step 3)
|
||||
const status = projectData.project.project_status;
|
||||
if (status === 'draft' || status === 'created') {
|
||||
setCurrentStep(2); // Go to geometry for new projects
|
||||
} else {
|
||||
// For existing projects, start at geometry but user can click to Smart Pakke
|
||||
setCurrentStep(2);
|
||||
}
|
||||
const nextStep = getProjectStepFromStatus(status);
|
||||
setCurrentStep(nextStep === 1 ? 2 : nextStep);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error selecting project:', error);
|
||||
@@ -388,44 +391,6 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
console.log('🔄 Switched to step 2 (Geometry) with project:', newProject.id);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleGeometryComplete = (geometryData) => {
|
||||
setGeometry(geometryData);
|
||||
setCurrentStep(3);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleLaborComplete = (laborData) => {
|
||||
setLabor(laborData);
|
||||
if (project?.id) {
|
||||
saveToSession(project.id, 'labor', laborData);
|
||||
}
|
||||
setCurrentStep(5); // Go to Materials step after Labor is complete
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleMaterialsUpdate = (materialsData) => {
|
||||
setMaterials(materialsData);
|
||||
if (project?.id) {
|
||||
saveToSession(project.id, 'materials', materialsData);
|
||||
}
|
||||
// Don't change step - just update the materials state
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleMaterialsComplete = (materialsData) => {
|
||||
setMaterials(materialsData);
|
||||
if (project?.id) {
|
||||
saveToSession(project.id, 'materials', materialsData);
|
||||
}
|
||||
setCurrentStep(6); // Go to Calculation step after Materials is complete
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleCalculationComplete = (calculationData) => {
|
||||
setCalculation(calculationData);
|
||||
};
|
||||
|
||||
// Handle back from Final Review with data saving
|
||||
const handleBackFromFinalReview = (editedData) => {
|
||||
console.log('💾 Saving edited data from Final Review...', editedData);
|
||||
@@ -434,7 +399,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
const updatedPackageData = {
|
||||
...packageData,
|
||||
materials: editedData.materials || packageData?.materials || [],
|
||||
laborTasks: editedData.laborTasks || packageData?.laborTasks || []
|
||||
laborTasks: editedData.laborTasks || packageData?.laborTasks || [],
|
||||
recommendedDescription: editedData.recommendedDescription ?? packageData?.recommendedDescription ?? ''
|
||||
};
|
||||
|
||||
saveToSession(project.id, 'packageData', updatedPackageData);
|
||||
@@ -481,13 +447,6 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
}
|
||||
}, [currentStep, project, geometry, labor, materials, calculation]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const goToStep = (stepNumber) => {
|
||||
if (stepNumber <= currentStep) {
|
||||
setCurrentStep(stepNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFlow = () => {
|
||||
setCurrentStep(1);
|
||||
setProject(null);
|
||||
@@ -498,13 +457,34 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
setSelectedProjectId(null);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const canAccessStep = (stepNumber) => {
|
||||
return stepNumber <= currentStep;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="project-flow">
|
||||
{!project && (
|
||||
<section className="project-flow-hero">
|
||||
<div className="project-flow-hero-copy">
|
||||
<p className="project-flow-eyebrow">Projektflow</p>
|
||||
<h1>Start i et samlet tilbudsflow</h1>
|
||||
<p className="project-flow-lead">
|
||||
Opret eller vælg et projekt for at låse geometri, smart pakker og final review op i den rigtige rækkefølge.
|
||||
</p>
|
||||
</div>
|
||||
<div className="project-flow-hero-stats">
|
||||
<div className="project-flow-stat">
|
||||
<strong>{projects.length}</strong>
|
||||
<span>Projekter klar</span>
|
||||
</div>
|
||||
<div className="project-flow-stat">
|
||||
<strong>{steps.length}</strong>
|
||||
<span>Faste trin</span>
|
||||
</div>
|
||||
<div className="project-flow-stat">
|
||||
<strong>{isLoadingProjectData ? 'Live' : 'Klar'}</strong>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Header with project info */}
|
||||
{project && (
|
||||
<div className="project-header">
|
||||
@@ -516,6 +496,15 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
<p>👤 Kunde: {project.customer_name}</p>
|
||||
<p>📝 {project.project_description}</p>
|
||||
</div>
|
||||
<div className="project-header-meta">
|
||||
<div className="project-progress-pill">
|
||||
<span>Flow status</span>
|
||||
<strong>{completedStepCount}/{steps.length} trin klar</strong>
|
||||
</div>
|
||||
<div className="project-progress-note">
|
||||
{isLoadingProjectData ? 'Synkroniserer projektdata...' : 'Projektdata er indlæst'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="reset-btn" onClick={resetFlow}>
|
||||
🔄 Nyt Projekt
|
||||
</button>
|
||||
@@ -526,7 +515,11 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
{isLoadingProjectData && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-message">
|
||||
🔄 Indlæser projektdata...
|
||||
<div className="loading-spinner" />
|
||||
<div>
|
||||
<strong>Indlæser projektdata</strong>
|
||||
<p>Geometri, materialer og pakker bliver synkroniseret.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -668,12 +661,16 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
onBackWithData={handleBackFromFinalReview}
|
||||
/>
|
||||
) : (
|
||||
<div style={{padding: '50px', textAlign: 'center'}}>
|
||||
<h3>⚠️ Debug: Manglende data for Final Review</h3>
|
||||
<p>Project: {project ? '✅' : '❌'}</p>
|
||||
<p>Enhanced Geometry: {enhancedGeometry ? '✅' : '❌'}</p>
|
||||
<p>Package Data: {packageData ? '✅' : '❌'}</p>
|
||||
<button onClick={() => setCurrentStep(3)} style={{padding: '10px 20px', marginTop: '20px'}}>
|
||||
<div className="flow-warning-card">
|
||||
<div className="flow-warning-icon">⚠️</div>
|
||||
<h3>Final Review mangler stadig data</h3>
|
||||
<p>Du skal have projekt, geometri og smart pakke klar før tilbuddet kan gennemgås.</p>
|
||||
<div className="flow-warning-checklist">
|
||||
<span>{project ? '✅' : '❌'} Projekt</span>
|
||||
<span>{enhancedGeometry ? '✅' : '❌'} Geometri</span>
|
||||
<span>{packageData ? '✅' : '❌'} Smart pakke</span>
|
||||
</div>
|
||||
<button className="flow-warning-btn" onClick={() => setCurrentStep(3)}>
|
||||
← Gå tilbage til Smart Pakke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export const PROJECT_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
GEOMETRY_COMPLETE: 'geometry_complete',
|
||||
SMART_PACKAGE_COMPLETE: 'smart_package_complete',
|
||||
REVIEW_PENDING: 'review_pending',
|
||||
READY_FOR_ORDRESTYRING: 'ready_for_ordrestyring',
|
||||
SENT_TO_ORDRESTYRING: 'sent_to_ordrestyring',
|
||||
ACCEPTED: 'accepted',
|
||||
REJECTED: 'rejected',
|
||||
GEOMETRY_PENDING: 'geometry_pending',
|
||||
LABOR_PENDING: 'labor_pending',
|
||||
MATERIALS_PENDING: 'materials_pending',
|
||||
CALCULATION_READY: 'calculation_ready',
|
||||
QUOTE_GENERATED: 'quote_generated',
|
||||
SENT: 'sent'
|
||||
};
|
||||
|
||||
const STATUS_META = {
|
||||
[PROJECT_STATUS.DRAFT]: { text: 'Kladde', color: '#6b7280' },
|
||||
[PROJECT_STATUS.GEOMETRY_COMPLETE]: { text: 'Geometri klar', color: '#2563eb' },
|
||||
[PROJECT_STATUS.SMART_PACKAGE_COMPLETE]: { text: 'Smart Pakke klar', color: '#7c3aed' },
|
||||
[PROJECT_STATUS.REVIEW_PENDING]: { text: 'Review', color: '#f59e0b' },
|
||||
[PROJECT_STATUS.READY_FOR_ORDRESTYRING]: { text: 'Klar til Ordrestyring', color: '#0f766e' },
|
||||
[PROJECT_STATUS.SENT_TO_ORDRESTYRING]: { text: 'Afsendt til Ordrestyring', color: '#059669' },
|
||||
[PROJECT_STATUS.ACCEPTED]: { text: 'Accepteret', color: '#16a34a' },
|
||||
[PROJECT_STATUS.REJECTED]: { text: 'Afvist', color: '#dc2626' },
|
||||
[PROJECT_STATUS.GEOMETRY_PENDING]: { text: 'Manglende geometri', color: '#6b7280' },
|
||||
[PROJECT_STATUS.LABOR_PENDING]: { text: 'Manglende timer', color: '#6b7280' },
|
||||
[PROJECT_STATUS.MATERIALS_PENDING]: { text: 'Manglende materialer', color: '#6b7280' },
|
||||
[PROJECT_STATUS.CALCULATION_READY]: { text: 'Beregnet', color: '#7c3aed' },
|
||||
[PROJECT_STATUS.QUOTE_GENERATED]: { text: 'Tilbud klar', color: '#f59e0b' },
|
||||
[PROJECT_STATUS.SENT]: { text: 'Sendt', color: '#059669' }
|
||||
};
|
||||
|
||||
export function getProjectStatusMeta(status) {
|
||||
return STATUS_META[status] || { text: status || 'Ukendt', color: '#6b7280' };
|
||||
}
|
||||
|
||||
export function getProjectStepFromStatus(status) {
|
||||
if (
|
||||
[
|
||||
PROJECT_STATUS.REVIEW_PENDING,
|
||||
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
|
||||
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
|
||||
PROJECT_STATUS.ACCEPTED,
|
||||
PROJECT_STATUS.REJECTED,
|
||||
PROJECT_STATUS.QUOTE_GENERATED,
|
||||
PROJECT_STATUS.SENT
|
||||
].includes(status)
|
||||
) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
PROJECT_STATUS.SMART_PACKAGE_COMPLETE,
|
||||
PROJECT_STATUS.CALCULATION_READY,
|
||||
PROJECT_STATUS.MATERIALS_PENDING
|
||||
].includes(status)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
PROJECT_STATUS.GEOMETRY_COMPLETE,
|
||||
PROJECT_STATUS.GEOMETRY_PENDING,
|
||||
PROJECT_STATUS.LABOR_PENDING
|
||||
].includes(status)
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function isFinalizedProjectStatus(status) {
|
||||
return [
|
||||
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
|
||||
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
|
||||
PROJECT_STATUS.ACCEPTED,
|
||||
PROJECT_STATUS.REJECTED,
|
||||
PROJECT_STATUS.SENT
|
||||
].includes(status);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
const { chromium } = require('@playwright/test');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4132';
|
||||
const ARTIFACTS_DIR = path.join(__dirname, 'artifacts', 'status-flow');
|
||||
|
||||
const LOGIN_USERNAME = 'toemrer';
|
||||
const LOGIN_PASSWORD = 'tilbud2024';
|
||||
|
||||
async function ensureArtifactsDir() {
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
const onLogin = await page.getByRole('heading', { name: /Log ind/i }).isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (!onLogin) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.locator('input[type="text"]').first().fill(LOGIN_USERNAME);
|
||||
await page.locator('input[type="password"]').first().fill(LOGIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Log ind/i }).click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
async function getProjectSnapshot(page, projectName) {
|
||||
return await page.evaluate(async ({ projectName }) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const response = await fetch('/api/customer-projects/projects', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const data = await response.json();
|
||||
const project = (data.projects || []).find((item) => item.project_name === projectName);
|
||||
|
||||
return project ? {
|
||||
id: project.id,
|
||||
status: project.project_status,
|
||||
customer_name: project.customer_name
|
||||
} : null;
|
||||
}, { projectName });
|
||||
}
|
||||
|
||||
async function waitForProjectSnapshot(page, projectName, maxAttempts = 10) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const snapshot = await getProjectSnapshot(page, projectName);
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fillGeometry(page) {
|
||||
const widthInput = page.locator('input[placeholder="10"]').first();
|
||||
const lengthInput = page.locator('input[placeholder="15"]').first();
|
||||
|
||||
await widthInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await widthInput.fill('10');
|
||||
await lengthInput.fill('12');
|
||||
|
||||
const saveBtn = page.getByRole('button', { name: /Gem Nu|Gem Geometri/i }).first();
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
async function completeSmartPackage(page) {
|
||||
const continueBtn = page.getByRole('button', { name: /Fortsæt til Smart Pakke/i }).first();
|
||||
if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await continueBtn.click();
|
||||
} else {
|
||||
await page.locator('.step').filter({ hasText: /Smart Pakke/i }).first().click();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const packageOptions = page.locator('.package-option');
|
||||
const packageCount = await packageOptions.count();
|
||||
if (packageCount === 0) {
|
||||
throw new Error('Ingen smart pakker fundet i UI');
|
||||
}
|
||||
|
||||
await packageOptions.first().click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const saveSmartPackageBtn = page.getByRole('button', { name: /Gem Smart Pakke/i }).first();
|
||||
if (await saveSmartPackageBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await saveSmartPackageBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
async function goToFinalReview(page) {
|
||||
const nextBtn = page.getByRole('button', { name: /Næste.*Final Review/i }).first();
|
||||
if (await nextBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await nextBtn.click();
|
||||
} else {
|
||||
await page.locator('.step').filter({ hasText: /Final Review/i }).first().click();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureArtifactsDir();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1400 } });
|
||||
const projectName = `Status Flow ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await login(page);
|
||||
console.log('STEP login', JSON.stringify({ url: page.url() }));
|
||||
|
||||
const newProjectBtn = page.getByRole('button', { name: /Nyt Projekt/i });
|
||||
if (await newProjectBtn.isVisible({ timeout: 10000 }).catch(() => false)) {
|
||||
await newProjectBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.locator('#field-projectName').fill(projectName);
|
||||
await page.locator('#field-customerName').fill('Status Flow Testkunde');
|
||||
await page.locator('#field-customerEmail').fill('[email protected]');
|
||||
await page.locator('#field-projectDescription').fill('Test af persisted statusflow for tømrer med tagmål og smart pakke.');
|
||||
await page.getByRole('button', { name: /Gem Projekt/i }).click();
|
||||
await page.waitForTimeout(4000);
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '01-after-create.png'), fullPage: true });
|
||||
|
||||
let snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_create', JSON.stringify(snapshot));
|
||||
|
||||
await fillGeometry(page);
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '02-after-geometry.png'), fullPage: true });
|
||||
snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_geometry', JSON.stringify(snapshot));
|
||||
|
||||
await completeSmartPackage(page);
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '03-after-smart-package.png'), fullPage: true });
|
||||
snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_smart_package', JSON.stringify(snapshot));
|
||||
|
||||
await goToFinalReview(page);
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '04-final-review.png'), fullPage: true });
|
||||
snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_review', JSON.stringify({
|
||||
snapshot,
|
||||
hasReadyButton: await page.getByRole('button', { name: /Klar til Ordrestyring/i }).isVisible().catch(() => false),
|
||||
hasSendButton: await page.getByRole('button', { name: /Send til Ordrestyring/i }).isVisible().catch(() => false)
|
||||
}));
|
||||
|
||||
const readyBtn = page.getByRole('button', { name: /Klar til Ordrestyring/i }).first();
|
||||
if (await readyBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await readyBtn.click();
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '05-after-ready.png'), fullPage: true });
|
||||
snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_ready', JSON.stringify(snapshot));
|
||||
|
||||
const sendBtn = page.getByRole('button', { name: /Send til Ordrestyring/i }).first();
|
||||
let sendResult = 'button_not_found';
|
||||
if (await sendBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await sendBtn.click();
|
||||
await page.waitForTimeout(15000);
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
if (bodyText.includes('Tilbud sendt til ordrestyring')) {
|
||||
sendResult = 'success';
|
||||
} else if (bodyText.includes('Fejl ved indsendelse')) {
|
||||
sendResult = 'error';
|
||||
} else {
|
||||
sendResult = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '06-after-send.png'), fullPage: true });
|
||||
snapshot = await waitForProjectSnapshot(page, projectName);
|
||||
console.log('STEP after_send', JSON.stringify({ snapshot, sendResult }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('STATUS_FLOW_ERROR', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user