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}