feat: Enhance material pricing integration by adding JOINs in API and updating SmartPackageForm to auto-add tasks from installation manuals

This commit is contained in:
alexpolo1
2025-11-07 12:01:07 +00:00
parent 0c186eb06f
commit 12263aef2e
5 changed files with 532 additions and 10 deletions
+218
View File
@@ -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);
});
+26 -3
View File
@@ -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)]
);
+232
View File
@@ -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!
@@ -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);
@@ -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: <InputAdornment position="end">kr/stk</InputAdornment>,
endAdornment: <InputAdornment position="end">kr/{materialData.unit}</InputAdornment>,
}}
/>
</Grid>
@@ -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: <InputAdornment position="end">kr/stk</InputAdornment>,
endAdornment: <InputAdornment position="end">kr/{materialData.unit}</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Box p={2} textAlign="right">
<Box p={2} textAlign="right" bgcolor="grey.50" borderRadius={1}>
<Typography variant="body2" color="textSecondary" gutterBottom>
Effektiv pris
Enhedspris
</Typography>
<Typography variant="h6">
{((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}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box p={2} textAlign="right" bgcolor="primary.light" borderRadius={1} sx={{ bgcolor: 'primary.light', color: 'primary.contrastText' }}>
<Typography variant="body2" gutterBottom fontWeight="bold">
Total pris
</Typography>
<Typography variant="h5" fontWeight="bold">
{(((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0) * (materialData.quantity || 0)).toFixed(2)} kr
</Typography>
<Typography variant="caption" display="block">
{materialData.quantity || 0} × {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/{materialData.unit}
</Typography>
</Box>
</Grid>