From 12263aef2e31726100986e24ef9cd5f22c9157c8 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 7 Nov 2025 12:01:07 +0000 Subject: [PATCH] feat: Enhance material pricing integration by adding JOINs in API and updating SmartPackageForm to auto-add tasks from installation manuals --- backend/scripts/import-bygma-prices.js | 218 ++++++++++++++++ backend/unified-server.js | 29 ++- docs/MATERIALE_PRIS_FIX.md | 232 ++++++++++++++++++ .../smartPackages/SmartPackageForm.js | 35 ++- .../smartPackages/dialogs/MaterialDialog.js | 28 ++- 5 files changed, 532 insertions(+), 10 deletions(-) create mode 100755 backend/scripts/import-bygma-prices.js create mode 100644 docs/MATERIALE_PRIS_FIX.md diff --git a/backend/scripts/import-bygma-prices.js b/backend/scripts/import-bygma-prices.js new file mode 100755 index 0000000..4477f08 --- /dev/null +++ b/backend/scripts/import-bygma-prices.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * Import prices from bygma_products to material_prices + * Matches materials by name similarity and imports current_netto_pris + */ + +const mysql = require('mysql2/promise'); + +const dbConfig = { + host: '127.0.0.1', + user: 'tilbudgivern_service', + password: 'REDACTED_PASSWORD', + database: 'tilbudgivern' +}; + +// Calculate simple similarity score between two strings +function similarity(s1, s2) { + const longer = s1.length > s2.length ? s1 : s2; + const shorter = s1.length > s2.length ? s2 : s1; + + if (longer.length === 0) return 1.0; + + const editDistance = levenshteinDistance(longer.toLowerCase(), shorter.toLowerCase()); + return (longer.length - editDistance) / longer.length; +} + +// Levenshtein distance algorithm +function levenshteinDistance(str1, str2) { + const costs = []; + for (let i = 0; i <= str1.length; i++) { + let lastValue = i; + for (let j = 0; j <= str2.length; j++) { + if (i === 0) { + costs[j] = j; + } else { + if (j > 0) { + let newValue = costs[j - 1]; + if (str1.charAt(i - 1) !== str2.charAt(j - 1)) { + newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; + } + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) costs[str2.length] = lastValue; + } + return costs[str2.length]; +} + +// Check if material name contains key words from bygma product +function containsKeyWords(materialName, bygmaText) { + const materialWords = materialName.toLowerCase().split(/\s+/).filter(w => w.length > 2); + const bygmaWords = bygmaText.toLowerCase().split(/\s+/); + + if (materialWords.length === 0) return 0; + + let matchCount = 0; + for (const mWord of materialWords) { + if (bygmaWords.some(bWord => bWord.includes(mWord) || mWord.includes(bWord))) { + matchCount++; + } + } + + return matchCount / materialWords.length; +} + +async function main() { + const connection = await mysql.createConnection(dbConfig); + + try { + console.log('🔍 Finding materials without prices...\n'); + + // Get materials without prices + const [materials] = await connection.execute(` + SELECT m.id, m.name, m.unit, m.category + FROM materials m + LEFT JOIN material_prices mp ON m.id = mp.material_id AND mp.is_active = 1 + WHERE mp.id IS NULL + ORDER BY m.id + `); + + console.log(`Found ${materials.length} materials without prices\n`); + + if (materials.length === 0) { + console.log('✅ All materials already have prices!'); + await connection.end(); + return; + } + + // Get all active bygma products with prices + const [bygmaProducts] = await connection.execute(` + SELECT id, tekst, enhed, current_netto_pris, category_name + FROM bygma_products + WHERE is_active = 1 AND current_netto_pris IS NOT NULL + ORDER BY tekst + `); + + console.log(`Loaded ${bygmaProducts.length} Bygma products with prices\n`); + console.log('─'.repeat(80)); + + const matches = []; + let autoImportCount = 0; + let manualReviewCount = 0; + + // Match each material to best bygma product + for (const material of materials) { + let bestMatch = null; + let bestScore = 0; + + for (const bygma of bygmaProducts) { + // Calculate similarity score + const nameSimilarity = similarity(material.name, bygma.tekst); + const keywordScore = containsKeyWords(material.name, bygma.tekst); + + // Bonus if units match (normalize units) + const materialUnit = (material.unit || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const bygmaUnit = (bygma.enhed || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const unitBonus = materialUnit === bygmaUnit ? 0.2 : 0; + + // Bonus if categories match + const categoryBonus = material.category && bygma.category_name && + material.category.toLowerCase().includes(bygma.category_name.toLowerCase()) ? 0.1 : 0; + + const totalScore = (nameSimilarity * 0.5) + (keywordScore * 0.3) + unitBonus + categoryBonus; + + if (totalScore > bestScore) { + bestScore = totalScore; + bestMatch = bygma; + } + } + + if (bestMatch && bestScore >= 0.3) { + matches.push({ + material, + bygma: bestMatch, + score: bestScore, + autoImport: bestScore >= 0.6 // High confidence threshold + }); + + if (bestScore >= 0.6) { + autoImportCount++; + } else { + manualReviewCount++; + } + } + } + + console.log(`\n📊 Match Results:`); + console.log(` High confidence (auto-import): ${autoImportCount}`); + console.log(` Medium confidence (review): ${manualReviewCount}`); + console.log(` No match found: ${materials.length - matches.length}\n`); + console.log('─'.repeat(80)); + + // Show matches and ask for confirmation + console.log('\n🎯 Matches found:\n'); + + for (const match of matches) { + const confidence = match.autoImport ? '✅ AUTO' : '⚠️ REVIEW'; + const scorePercent = (match.score * 100).toFixed(0); + + console.log(`${confidence} [${scorePercent}%] Material: "${match.material.name}" (${match.material.unit})`); + console.log(` → Bygma: "${match.bygma.tekst}" (${match.bygma.enhed})`); + console.log(` → Price: ${parseFloat(match.bygma.current_netto_pris).toFixed(2)} DKK`); + console.log(''); + } + + console.log('─'.repeat(80)); + console.log(`\n💾 Importing ${autoImportCount} high-confidence matches...\n`); + + // Import high-confidence matches + let importedCount = 0; + for (const match of matches.filter(m => m.autoImport)) { + try { + await connection.execute(` + INSERT INTO material_prices + (name, material_id, price, currency, valid_from, is_active, unit, supplier_name, category) + VALUES (?, ?, ?, 'DKK', CURDATE(), 1, ?, 'Bygma', ?) + `, [ + match.bygma.tekst, + match.material.id, + match.bygma.current_netto_pris, + match.bygma.enhed, + match.material.category + ]); + + importedCount++; + console.log(`✅ Imported: ${match.material.name} → ${parseFloat(match.bygma.current_netto_pris).toFixed(2)} DKK`); + } catch (error) { + console.error(`❌ Error importing ${match.material.name}: ${error.message}`); + } + } + + console.log(`\n✨ Successfully imported ${importedCount} prices!`); + + if (manualReviewCount > 0) { + console.log(`\n⚠️ ${manualReviewCount} matches need manual review (score < 60%)`); + console.log(' Review the medium-confidence matches above and import manually if correct.'); + } + + if (materials.length - matches.length > 0) { + console.log(`\n❓ ${materials.length - matches.length} materials had no suitable Bygma match`); + console.log(' Consider adding prices manually or improving product descriptions.'); + } + + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await connection.end(); + } +} + +// Run the script +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/backend/unified-server.js b/backend/unified-server.js index 3e04349..6c23aac 100644 --- a/backend/unified-server.js +++ b/backend/unified-server.js @@ -2988,17 +2988,40 @@ app.get('/api/materials', async (req, res) => { const params = []; if (category) { - whereClause += ' AND category = ?'; + whereClause += ' AND m.category = ?'; params.push(category); } if (search) { - whereClause += ' AND (name LIKE ? OR description LIKE ? OR sku LIKE ?)'; + whereClause += ' AND (m.name LIKE ? OR m.description LIKE ? OR m.sku LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`); } const materials = await databaseService.query( - `SELECT * FROM materials ${whereClause} ORDER BY name ASC LIMIT ? OFFSET ?`, + `SELECT + m.*, + COALESCE(mp.price, bp.current_netto_pris) as price, + COALESCE(mp.unit, bp.enhed) as price_unit, + mp.valid_from, + COALESCE(mp.supplier_name, 'Bygma') as price_supplier, + bp.tekst as bygma_product_name + FROM materials m + LEFT JOIN material_prices mp ON m.id = mp.material_id + AND mp.is_active = 1 + AND mp.id = ( + SELECT id + FROM material_prices + WHERE material_id = m.id + AND is_active = 1 + ORDER BY valid_from DESC, id DESC + LIMIT 1 + ) + LEFT JOIN bygma_materials_mapping bmm ON m.id = bmm.material_id + LEFT JOIN bygma_products bp ON bmm.bygma_product_id = bp.id + AND bp.is_active = 1 + ${whereClause} + ORDER BY m.name ASC + LIMIT ? OFFSET ?`, [...params, parseInt(limit), parseInt(offset)] ); diff --git a/docs/MATERIALE_PRIS_FIX.md b/docs/MATERIALE_PRIS_FIX.md new file mode 100644 index 0000000..425ec24 --- /dev/null +++ b/docs/MATERIALE_PRIS_FIX.md @@ -0,0 +1,232 @@ +# Materiale Pris Integration Fix + +## Dato: 2024-11-07 +## Status: ✅ Færdig + +## Problem + +Når man tilføjer et materiale i SmartPackageForm via "Tilføj materiale" knappen, viste prisen altid 0.00 kr selvom materialer har priser i databasen. + +### Symptomer +- MaterialDialog viste "Standardpris: 0.00 kr/stk" +- Effektiv pris viste også 0.00 kr/stk +- Brugerdefineret pris skulle indtastes manuelt + +### Root Cause +`/api/materials` endpoint hentede kun data fra `materials` tabellen uden at JOIN'e med `material_prices` tabellen hvor priserne faktisk er gemt. + +## Løsning + +### Backend Ændringer + +**Fil:** `/backend/unified-server.js` + +**Ændring 1: Tilføjet JOIN med material_prices** +```javascript +const materials = await databaseService.query( + `SELECT + m.*, + mp.price, + mp.unit as price_unit, + mp.valid_from, + mp.supplier_name as price_supplier + FROM materials m + LEFT JOIN material_prices mp ON m.id = mp.material_id + AND mp.is_active = 1 + AND mp.id = ( + SELECT id + FROM material_prices + WHERE material_id = m.id + AND is_active = 1 + ORDER BY valid_from DESC, id DESC + LIMIT 1 + ) + ${whereClause} + ORDER BY m.name ASC + LIMIT ? OFFSET ?`, + [...params, parseInt(limit), parseInt(offset)] +); +``` + +**Ændring 2: Rettede ambiguous column names** +```javascript +// FØR: +if (search) { + whereClause += ' AND (name LIKE ? OR description LIKE ? OR sku LIKE ?)'; +} + +// EFTER: +if (search) { + whereClause += ' AND (m.name LIKE ? OR m.description LIKE ? OR m.sku LIKE ?)'; +} +``` + +### Hvordan det virker + +1. **LEFT JOIN:** Alle materialer vises, også dem uden priser +2. **Subquery:** Finder den nyeste aktive pris for hvert materiale +3. **ORDER BY valid_from DESC:** Bruger den senest gyldige pris +4. **is_active = 1:** Kun aktive priser inkluderes + +### Database Schema + +**Tabeller involveret:** +- `materials` - Materiale grunddata (id, name, unit, description, etc.) +- `material_prices` - Priser for materialer (material_id, price, valid_from, etc.) + +**Relationer:** +``` +materials.id ← material_prices.material_id (1:N) +``` + +## Test Resultater + +### Før Fix +```bash +curl http://localhost:4031/api/materials?limit=5 +``` +```json +{ + "id": 16, + "name": "B7 tagplader", + "unit": "m2", + "price": null // ❌ Ingen pris +} +``` + +### Efter Fix +```bash +curl http://localhost:4031/api/materials?search=B7%20tag +``` +```json +{ + "id": 16, + "name": "B7 tagplader", + "unit": "m2", + "price": "125.00" // ✅ Pris hentet fra material_prices +} +``` + +### Test Case: Eternit tagsten +```json +{ + "id": 37, + "name": "Eternit tagsten B6 rød", + "unit": "m2", + "price": "145.00" +} +``` + +## Frontend Integration + +MaterialDialog modtager nu automatisk pris fra backend: + +**Før (linje 105-107 i MaterialDialog.js):** +```javascript +const materialPrice = value.price ? parseFloat(value.price) : 0; +// value.price var altid undefined → price = 0 +``` + +**Efter:** +```javascript +const materialPrice = value.price ? parseFloat(value.price) : 0; +// value.price = "125.00" fra database → price = 125.00 ✅ +``` + +### Autocomplete Visning +MaterialDialog's Autocomplete viser nu priser: +```javascript +getOptionLabel={(option) => { + const priceInfo = option.price + ? ` - ${parseFloat(option.price).toFixed(2)} kr/${option.unit}` + : ''; + return `${option.name}${priceInfo}`; +}} +``` + +**Resultat:** +- "B7 tagplader - 125.00 kr/m2" +- "Eternit tagsten B6 rød - 145.00 kr/m2" + +## Materialer med Priser + +Efter fix har følgende materialer priser: + +| ID | Materiale | Pris | Enhed | +|----|-----------|------|-------| +| 16 | B7 tagplader | 125.00 | m2 | +| 35 | Test materiale | 150.00 | stk | +| 36 | Test materiale efter fix | 200.00 | m2 | +| 37 | Eternit tagsten B6 rød | 145.00 | m2 | +| 38 | Tagpap underlag T400 | 35.50 | m2 | +| 39 | Tagplade stål 35/1035 sort | 185.00 | m2 | +| 40 | Tagrender halvrund zink 150mm | 245.00 | m | +| 41 | Nedløbsrør zink 100mm | 195.00 | m | +| 42 | Stålskotskinne galvaniseret | 45.00 | m | +| 43 | Tagisolering mineraluld 200mm | 85.00 | m2 | +| 44 | Eternit vindskeder B6 | 75.00 | stk | +| 45 | Eternit rygningsten B6 | 195.00 | m | +| 46 | Lægter 38x63mm trykimprægneret | 18.50 | m | + +## Deployment + +```bash +# Genstart backend +pm2 restart tilbudgivern-unified + +# Frontend kræver ikke rebuild - ændringen er kun i backend +``` + +### Server Status +- Backend: Online (port 4031) - 11 restarts +- Frontend: Online (port 3001) - 5 restarts + +## Known Limitations + +1. **Materialer uden priser:** Vises stadig i listen men med `price: null` +2. **Historiske priser:** Kun den nyeste pris vises (baseret på `valid_from`) +3. **Multiple leverandører:** Hvis samme materiale har flere priser, vises kun den nyeste + +## Fremtidige Forbedringer + +### 1. Pris Import System +Automatisk import af priser fra leverandører: +- Bygma prisliste +- CSV upload +- API integration + +### 2. Pris Historik +Vis prisudvikling over tid: +```sql +SELECT + material_id, + price, + valid_from, + supplier_name +FROM material_prices +WHERE material_id = ? +ORDER BY valid_from DESC +``` + +### 3. Leverandør Valg +Lad brugeren vælge mellem forskellige leverandører: +```javascript +const [selectedSupplier, setSelectedSupplier] = useState('Bygma'); +// Filtrer priser baseret på supplier_name +``` + +### 4. Bulk Pris Opdatering +Interface til at opdatere mange priser på én gang: +- Upload CSV fil med nye priser +- Automatisk validering +- Preview før import + +## Konklusion + +✅ Materialer trækker nu automatisk priser fra databasen +✅ MaterialDialog viser korrekt standardpris +✅ Autocomplete viser priser i dropdown +✅ Brugerdefineret pris kan stadig anvendes til at overskrive +✅ Ingen frontend ændringer nødvendige + +Systemet er nu fuldt funktionelt med prisintegration! diff --git a/frontend/src/components/smartPackages/SmartPackageForm.js b/frontend/src/components/smartPackages/SmartPackageForm.js index 92c1afd..55109a5 100644 --- a/frontend/src/components/smartPackages/SmartPackageForm.js +++ b/frontend/src/components/smartPackages/SmartPackageForm.js @@ -281,9 +281,42 @@ const SmartPackageForm = ({ packageId, onBack }) => { updatedMaterials = [...packageData.materials, newMaterial]; } + // Automatisk tilføj opgaver fra installation manual hvis de findes + let updatedTasks = packageData.tasks; + if (materialData.installationManual && materialData.installationManual.installation_steps) { + const manual = materialData.installationManual; + const steps = Array.isArray(manual.installation_steps) + ? manual.installation_steps + : JSON.parse(manual.installation_steps || '[]'); + + // Opret opgaver fra installation steps + if (steps.length > 0) { + const newTasks = steps.map((step, index) => ({ + id: `temp-manual-${Date.now()}-${index}`, + name: step.title || step.step || `Trin ${index + 1}`, + description: step.description || step.details || '', + category: 'Montage', + estimated_hours: step.estimated_time ? parseFloat(step.estimated_time) : 1, + hourly_rate: 450, // Standard timepris + steps: step.details ? [step.details] : [], + material_reference: materialData.material_name || materialData.description, + from_installation_manual: true, + manual_product: manual.product_name + })); + + updatedTasks = [...packageData.tasks, ...newTasks]; + + enqueueSnackbar( + `${newTasks.length} opgave${newTasks.length > 1 ? 'r' : ''} tilføjet automatisk fra montagevejledning`, + { variant: 'success' } + ); + } + } + setPackageData({ ...packageData, - materials: updatedMaterials + materials: updatedMaterials, + tasks: updatedTasks }); setMaterialDialogOpen(false); diff --git a/frontend/src/components/smartPackages/dialogs/MaterialDialog.js b/frontend/src/components/smartPackages/dialogs/MaterialDialog.js index e252c80..224594a 100644 --- a/frontend/src/components/smartPackages/dialogs/MaterialDialog.js +++ b/frontend/src/components/smartPackages/dialogs/MaterialDialog.js @@ -244,7 +244,9 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials }) const dataToSave = { ...materialData, // Hvis der ikke bruges formel, fjern formlen - quantity_formula: materialData.use_formula ? materialData.quantity_formula : null + quantity_formula: materialData.use_formula ? materialData.quantity_formula : null, + // Tilføj installation manual data hvis det findes + installationManual: installationManual || null }; onSave(dataToSave); @@ -509,7 +511,7 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials }) disabled value={materialData.price.toFixed(2)} InputProps={{ - endAdornment: kr/stk, + endAdornment: kr/{materialData.unit}, }} /> @@ -524,18 +526,32 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials }) onChange={handleInputChange('custom_price')} placeholder="Lad være tom for at bruge standardpris" InputProps={{ - endAdornment: kr/stk, + endAdornment: kr/{materialData.unit}, }} /> - + - Effektiv pris + Enhedspris - {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/stk + {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/{materialData.unit} + + + + + + + + Total pris + + + {(((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0) * (materialData.quantity || 0)).toFixed(2)} kr + + + {materialData.quantity || 0} × {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/{materialData.unit}