This commit is contained in:
lejay
2026-02-13 00:21:53 +01:00
parent 81d2412835
commit 6923c319a1
85 changed files with 17836 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
/**
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
* Calculates all panel dimensions based on configuration and color assignments
*/
// Panel thickness in mm
const PANEL_THICKNESS = 19;
const BACK_PANEL_THICKNESS = 8;
// Edge banding settings
const EDGE_BANDING = {
visible: 2, // 2mm visible edge banding
hidden: 0.5 // 0.5mm thin edge banding
};
/**
* Element dimensions calculator
* Returns the dimensions for each element type based on wardrobe config
*/
function getElementDimensions(elementType, config, modules = []) {
const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = widthMm - (PANEL_THICKNESS * 2);
const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space
const isNiche = installationType === 'niche';
switch (elementType) {
case 'plintheBas':
return {
width: widthMm,
height: plinthHeightMm,
depth: depthMm,
quantity: 1,
description: 'Plinthe de base'
};
case 'bandeauHaut':
return {
width: widthMm,
height: PANEL_THICKNESS,
depth: depthMm,
quantity: 1,
description: 'Bandeau supérieur'
};
case 'dessus':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du dessus'
};
case 'fond':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du fond (bas)'
};
case 'cotes':
return {
width: PANEL_THICKNESS,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés extérieurs (gauche et droit)'
};
case 'cotesDoublage':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés de doublage (niche)'
};
case 'fileur':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Fileurs (bouche-trou niche)'
};
case 'dos':
return {
width: innerWidth,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: BACK_PANEL_THICKNESS,
quantity: 1,
description: 'Dos (panneau arrière)'
};
case 'portes':
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const isSliding = config.doorType === 'sliding';
if (isSliding) {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap
return {
width: panelWidth,
height: bodyHeight - 20, // clearance
depth: PANEL_THICKNESS,
quantity: panelCount,
description: `Portes coulissantes (${panelCount} vantaux)`
};
} else {
// Hinged doors - one per module/block
return {
width: Math.floor(widthMm / doorCount) - 4, // 2mm gap each side
height: bodyHeight - 4,
depth: PANEL_THICKNESS,
quantity: doorCount,
description: `Portes battantes (${doorCount} portes)`
};
}
case 'panneauxInterieurs':
// Vertical separators between modules
const separatorCount = modules.length > 1
? modules.length - 1
: (config.blocks?.length || 1) - 1;
if (separatorCount <= 0) return null;
return {
width: PANEL_THICKNESS,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: innerDepth,
quantity: separatorCount,
description: `Séparateurs verticaux (${separatorCount})`
};
case 'etageres':
// Count shelves from modules
const totalShelves = modules.reduce((sum, m) => sum + (m.shelves || 0), 0);
if (totalShelves <= 0) return null;
// Average shelf width based on modules
const avgModuleWidth = modules.length > 0
? modules.reduce((sum, m) => sum + (m.widthMm || 0), 0) / modules.length
: innerWidth;
return {
width: avgModuleWidth - (PANEL_THICKNESS * 2),
height: PANEL_THICKNESS,
depth: innerDepth - 20, // setback from front
quantity: totalShelves,
description: `Étagères (${totalShelves})`
};
case 'champs':
// Edge banding - calculated as linear meters
// Include all visible edges
const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4);
return {
width: EDGE_BANDING.visible,
height: perimeter,
depth: 0,
quantity: 1,
unit: 'ml',
description: 'Chant plaqué visible'
};
case 'caisse':
// The carcass/body - this is the assembled unit, not a cut piece
return null;
default:
return null;
}
}
/**
* Get edge banding requirements for an element
*/
function getEdgeBanding(elementType) {
const fullEdges = ['portes', 'cotes', 'dessus', 'plintheBas', 'bandeauHaut'];
const partialEdges = ['etageres', 'panneauxInterieurs', 'fond'];
if (fullEdges.includes(elementType)) {
return { type: 'visible', sides: 4 };
} else if (partialEdges.includes(elementType)) {
return { type: 'visible', sides: 1 }; // front edge only
} else if (elementType === 'dos') {
return { type: 'none', sides: 0 };
}
return { type: 'hidden', sides: 2 };
}
/**
* Generate complete cutting list for a wardrobe configuration
* @param {Object} config - Wardrobe configuration
* @param {Object} colors - Color/finish assignments per element
* @param {Array} modules - Module configurations (optional)
* @returns {Object} Cutting list with pieces grouped by finish
*/
export function generateCuttingList(config, colors = {}, modules = []) {
const pieces = [];
// All element types to process
const elementTypes = [
'plintheBas',
'bandeauHaut',
'dessus',
'fond',
'cotes',
'cotesDoublage',
'fileur',
'dos',
'portes',
'panneauxInterieurs',
'etageres'
];
for (const elementType of elementTypes) {
const dimensions = getElementDimensions(elementType, config, modules);
if (!dimensions) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
pieces.push({
id: `piece-${elementType}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: dimensions.depth <= PANEL_THICKNESS ? dimensions.depth : PANEL_THICKNESS,
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding: edgeBanding,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
});
}
// Calculate totals by finish
const byFinish = {};
for (const piece of pieces) {
const key = piece.finishLabel || 'Non défini';
if (!byFinish[key]) {
byFinish[key] = {
finishId: piece.finishId,
finishLabel: key,
pieces: [],
totalSurfaceM2: 0,
pieceCount: 0
};
}
byFinish[key].pieces.push(piece);
byFinish[key].totalSurfaceM2 += piece.surfaceM2;
byFinish[key].pieceCount += piece.quantity;
}
// Calculate edge banding totals
const edgeBandingMl = pieces.reduce((total, piece) => {
if (piece.edgeBanding.type !== 'none') {
const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters
return total + (perimeter * piece.edgeBanding.sides / 4);
}
return total;
}, 0);
return {
pieces,
byFinish: Object.values(byFinish),
summary: {
totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0),
totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0),
edgeBandingMl: Math.round(edgeBandingMl * 10) / 10,
finishCount: Object.keys(byFinish).length
},
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType
}
};
}
/**
* Calculate surface area in m²
*/
function calculateSurface(widthMm, heightMm, quantity = 1) {
return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000;
}
/**
* Format cutting list for display or export
*/
export function formatCuttingListForExport(cuttingList) {
const lines = [];
lines.push('LISTE DE DÉBITAGE');
lines.push('=================');
lines.push('');
lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`);
lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`);
lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`);
lines.push('');
lines.push('RÉSUMÉ');
lines.push('------');
lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`);
lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`);
lines.push(`Chant plaqué: ${cuttingList.summary.edgeBandingMl} ml`);
lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`);
lines.push('');
for (const group of cuttingList.byFinish) {
lines.push(`FINITION: ${group.finishLabel}`);
lines.push('-'.repeat(40));
lines.push('Pièce | Dim. (LxH) | Ép. | Qté | Surface');
for (const piece of group.pieces) {
lines.push(
`${piece.description.padEnd(30)} | ${piece.widthMm}x${piece.heightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m²`
);
}
lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`);
lines.push('');
}
return lines.join('\n');
}
export default { generateCuttingList, formatCuttingListForExport };
+213
View File
@@ -0,0 +1,213 @@
/**
* AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders
* Helps customers feel confident about their wardrobe design choices.
*/
import OpenAI from 'openai';
import crypto from 'crypto';
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return openaiClient;
}
/* -------------------------------------------------------------------------- */
/* 1. Design Tips (GPT-4o text) */
/* -------------------------------------------------------------------------- */
const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme.
À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum.
Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix.
Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances.
Réponds UNIQUEMENT en JSON :
{ "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`;
/**
* Generate a design tip for the current configuration
* @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType }
* @returns {{ tip: string, category: string }}
*/
export async function getDesignTip(config) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.9,
max_tokens: 200,
messages: [
{ role: 'system', content: DESIGN_TIP_PROMPT },
{ role: 'user', content: configSummary }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
return JSON.parse(raw);
} catch {
return { tip: raw, category: 'conseil' };
}
}
/* -------------------------------------------------------------------------- */
/* 2. Material Recommendations (GPT-4o) */
/* -------------------------------------------------------------------------- */
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
Critères de recommandation :
- Harmonie des couleurs avec les matériaux déjà sélectionnés
- Cohérence avec le style d'armoire (dimensions, configuration)
- Tendances actuelles en design d'intérieur suisse
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
Réponds UNIQUEMENT en JSON :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
]
}`;
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
// Compact finish list (only names + ids to save tokens)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
}
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
} catch {
return { recommendations: [] };
}
}
/* -------------------------------------------------------------------------- */
/* 3. Photorealistic Render (DALL-E 3) */
/* -------------------------------------------------------------------------- */
// Simple in-memory render cache (config hash → image URL)
const renderCache = new Map();
/**
* Generate a photorealistic render of the configured wardrobe using DALL-E 3
* @param {object} config - Current wardrobe configuration
* @param {object} [roomContext] - Optional room analysis for context
* @returns {{ imageUrl: string, cached: boolean }}
*/
export async function generateRender(config, roomContext) {
const client = getOpenAIClient();
// Cache by config hash
const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex');
if (renderCache.has(hash)) {
return { imageUrl: renderCache.get(hash), cached: true };
}
const prompt = buildRenderPrompt(config, roomContext);
const response = await client.images.generate({
model: 'dall-e-3',
prompt,
n: 1,
size: '1792x1024',
quality: 'hd',
style: 'natural'
});
const imageUrl = response.data[0]?.url;
if (imageUrl) {
renderCache.set(hash, imageUrl);
// Auto-evict after 30 minutes (DALL-E URLs expire after ~1h)
setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000);
}
return { imageUrl, cached: false };
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function buildConfigSummary(config) {
const columns = (config.columns || config.modules || [])
.map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`)
.join('\n');
return `Armoire sur mesure SIGNATURE
Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm
Installation: ${config.installationType || 'libre'}
Colonnes:\n${columns}
Matériau principal: ${config.materialName || config.finishLabel || 'non défini'}
Portes: ${config.doorType || 'battantes'}`;
}
function buildRenderPrompt(config, roomContext) {
const cols = (config.columns || config.modules || []);
const colDesc = cols.map((c, i) => {
const type = c.type || c.moduleCode || 'standard';
const parts = [];
if (c.hasRod) parts.push('a hanging rod');
if (c.shelves > 0) parts.push(`${c.shelves} shelves`);
if (c.drawers > 0) parts.push(`${c.drawers} drawers`);
return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`;
}).join('; ');
const widthM = ((config.width || 2400) / 1000).toFixed(1);
const heightM = ((config.height || 2400) / 1000).toFixed(1);
let roomDesc = 'a modern, well-lit bedroom with neutral walls';
if (roomContext) {
const walls = roomContext.walls || [];
const wallColors = walls.filter(w => w.color).map(w => w.color).join(', ');
roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`;
}
return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}.
The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}.
Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior.
Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level.
The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`;
}
+638
View File
@@ -0,0 +1,638 @@
/**
* Formex.ch web crawler service
* Scrapes panel/material data from the Formex catalog
*/
import * as cheerio from 'cheerio';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pool, { run } from '../db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE_URL = 'https://www.formex.ch';
const ASSORTIMENT_URL = `${BASE_URL}/fr/assortiment`;
const CRAWL_DELAY = parseInt(process.env.FORMEX_CRAWL_DELAY_MS || '500', 10);
// Directory for storing downloaded material images
const IMAGES_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'materials');
// Ensure images directory exists
if (!fs.existsSync(IMAGES_DIR)) {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
/**
* Sleep helper for rate limiting
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch a URL with error handling
*/
async function fetchPage(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-CH,fr;q=0.9'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
/**
* Download an image and save it locally
* @param {string} url - Image URL to download
* @param {string} filename - Local filename (e.g., "12345.jpg")
* @returns {string|null} - Local path or null if failed
*/
async function downloadImage(url, filename) {
try {
if (!url) return null;
// Ensure absolute URL
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
const response = await fetch(fullUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'image/*'
}
});
if (!response.ok) {
console.warn(`Failed to download image ${fullUrl}: ${response.status}`);
return null;
}
const contentType = response.headers.get('content-type') || '';
let ext = '.jpg';
if (contentType.includes('png')) ext = '.png';
else if (contentType.includes('webp')) ext = '.webp';
// Ensure filename has correct extension
const finalFilename = filename.replace(/\.[^.]+$/, '') + ext;
const filepath = path.join(IMAGES_DIR, finalFilename);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(filepath, buffer);
// Return the public path (relative to client/public)
return `/materials/${finalFilename}`;
} catch (err) {
console.warn(`Error downloading image ${url}:`, err.message);
return null;
}
}
/**
* Extract collection URLs from the assortiment page
*/
async function getCollections() {
const html = await fetchPage(ASSORTIMENT_URL);
const $ = cheerio.load(html);
const collections = [];
// Find collection links in the "Collections" section
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
const name = $(el).text().trim();
// Only include category pages (contain -c followed by numbers)
if (href && href.match(/-c\d+$/) && name) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
// Avoid duplicates
if (!collections.find(c => c.url === fullUrl)) {
collections.push({ name, url: fullUrl });
}
}
});
return collections;
}
/**
* Parse dimensions from a string like "2800mm x 2070mm x 19mm"
*/
function parseDimensions(dimStr) {
if (!dimStr) return { width: null, height: null, thickness: null };
const matches = dimStr.match(/(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm/i);
if (matches) {
return {
width: parseInt(matches[1], 10),
height: parseInt(matches[2], 10),
thickness: parseInt(matches[3], 10)
};
}
return { width: null, height: null, thickness: null };
}
/**
* Extract decor code from product name or page content
* e.g., "Panneaux mélaminés Duplex Plus 10000 Tilia" -> "10000"
*/
function extractDecorCode(text) {
// Look for a 4-5 digit code
const match = text.match(/\b(\d{4,5})\b/);
return match ? match[1] : null;
}
/**
* Map of common color keywords found in Formex product names to representative hex colors
*/
const COLOR_KEYWORDS = {
// Whites and off-whites
'blanc': '#f5f5f5', 'white': '#f5f5f5', 'weiss': '#f5f5f5', 'bianco': '#f5f5f5',
'crème': '#fffdd0', 'cream': '#fffdd0', 'ivoire': '#fffff0', 'ivory': '#fffff0',
'magnolia': '#f8f4f0', 'perle': '#eae0c8',
// Greys
'gris': '#909090', 'grey': '#909090', 'gray': '#909090', 'grau': '#909090',
'anthracite': '#383838', 'anthrazit': '#383838', 'graphite': '#4a4a4a',
'platine': '#e5e4e2', 'argent': '#c0c0c0', 'silver': '#c0c0c0',
'titane': '#878681',
// Blacks
'noir': '#1a1a1a', 'black': '#1a1a1a', 'schwarz': '#1a1a1a',
// Browns and woods
'chêne': '#b5895b', 'oak': '#b5895b', 'eiche': '#b5895b',
'noyer': '#6f4e37', 'walnut': '#6f4e37', 'nussbaum': '#6f4e37',
'hêtre': '#c9a46c', 'beech': '#c9a46c', 'buche': '#c9a46c',
'érable': '#d4b572', 'maple': '#d4b572', 'ahorn': '#d4b572',
'cerisier': '#9c3b17', 'cherry': '#9c3b17', 'kirsch': '#9c3b17',
'teck': '#b08444', 'teak': '#b08444',
'pin': '#dcc8a0', 'pine': '#dcc8a0',
'orme': '#a07850', 'elm': '#a07850',
'bouleau': '#e0c99c', 'birch': '#e0c99c',
'frêne': '#c8b88a', 'ash': '#c8b88a',
'acacia': '#b89244',
'wenge': '#4a3728', 'wengé': '#4a3728',
'mélèze': '#c4a06c', 'larch': '#c4a06c',
'aulne': '#b07c4c', 'alder': '#b07c4c',
'brun': '#8b4513', 'brown': '#8b4513', 'braun': '#8b4513',
'marron': '#6d3e1c', 'chocolat': '#4a2c14',
'cognac': '#9f381d', 'caramel': '#a67040',
'noisette': '#ab7746',
// Beiges and sandstones
'beige': '#d9c8a9', 'sable': '#c2b280', 'sand': '#c2b280',
'sahara': '#c4a661', 'champagne': '#f7e7ce',
'vanille': '#f3e5ab', 'vanilla': '#f3e5ab',
'cappuccino': '#a0785a', 'moka': '#6f4e37',
// Reds
'rouge': '#cc3333', 'red': '#cc3333', 'rot': '#cc3333',
'bordeaux': '#722f37', 'burgundy': '#722f37',
'terracotta': '#c65d3e', 'rouille': '#b7410e',
// Blues
'bleu': '#4169e1', 'blue': '#4169e1', 'blau': '#4169e1',
'marine': '#000080', 'navy': '#000080',
'ciel': '#87ceeb', 'azur': '#007fff',
'cobalt': '#0047ab', 'indigo': '#4b0082',
// Greens
'vert': '#4caf50', 'green': '#4caf50', 'grün': '#4caf50',
'olive': '#808000', 'sauge': '#9caf88', 'sage': '#9caf88',
'emeraude': '#50c878', 'mousse': '#738276',
// Yellows and golds
'jaune': '#f0c040', 'yellow': '#f0c040',
'or': '#d4af37', 'gold': '#d4af37',
'miel': '#cd8032', 'honey': '#cd8032',
// Stone and concrete
'béton': '#a0a0a0', 'beton': '#a0a0a0', 'concrete': '#a0a0a0',
'pierre': '#b0a898', 'stone': '#b0a898', 'stein': '#b0a898',
'ardoise': '#5a5a5a', 'slate': '#5a5a5a',
'marbre': '#e0d8d0', 'marble': '#e0d8d0',
'granit': '#808080', 'granite': '#808080',
'travertin': '#d1c6b0',
// Others
'rouille': '#b7410e', 'rust': '#b7410e',
'cuivre': '#b87333', 'copper': '#b87333',
'linen': '#e0d5c0', 'lin': '#e0d5c0',
'taupe': '#8b7d6b',
'mauve': '#e0b0ff', 'lilas': '#c8a2c8',
'rose': '#ffc0cb', 'pink': '#ffc0cb', 'rosa': '#ffc0cb'
};
/**
* Extract dominant color from product name by matching color keywords
* @param {string} name - Product name
* @returns {string|null} - Hex color code or null
*/
function extractDominantColor(name) {
if (!name) return null;
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const nameLower = name.toLowerCase();
// Try exact keyword matching (longest match first for compound words)
const sortedKeywords = Object.entries(COLOR_KEYWORDS)
.sort((a, b) => b[0].length - a[0].length);
for (const [keyword, hex] of sortedKeywords) {
// Check the raw name (preserving accents) and the normalized name
if (nameLower.includes(keyword) || lower.includes(keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))) {
return hex;
}
}
return null;
}
/**
* Get product details from a product detail page
* Enhanced to capture category, images, dimension variants, and color variants
*/
async function getProductDetails(productUrl) {
try {
const html = await fetchPage(productUrl);
const $ = cheerio.load(html);
// Get product name from h1
const name = $('h1').first().text().trim();
// Get decor code - look for "N° du décor:" or extract from name
let decorCode = null;
$('*').each((_, el) => {
const text = $(el).text();
const match = text.match(/N°\s*du\s*décor\s*:\s*(\d+)/i);
if (match) {
decorCode = match[1];
return false; // break
}
});
if (!decorCode) {
decorCode = extractDecorCode(name);
}
// Get category from breadcrumb (second-to-last item usually)
let category = null;
const breadcrumbLinks = $('a[href*="/fr/shop/"]').filter((_, el) => {
const href = $(el).attr('href') || '';
return href.match(/-c\d+$/); // Collection links
});
if (breadcrumbLinks.length > 0) {
category = $(breadcrumbLinks.last()).text().trim();
}
// Get main product image
let imageUrl = null;
// Look for product image in various selectors
$('img').each((_, el) => {
const src = $(el).attr('src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
// Try data-src for lazy-loaded images
if (!imageUrl) {
$('img[data-src]').each((_, el) => {
const src = $(el).attr('data-src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
}
// Get ALL dimension variants (multiple formats like "3650mm x 600mm x 29mm")
const dimensionVariants = [];
const seenDimensions = new Set();
$('a, button, span, div').each((_, el) => {
const text = $(el).text().trim();
const match = text.match(/^(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm$/i);
if (match) {
const key = `${match[1]}-${match[2]}-${match[3]}`;
if (!seenDimensions.has(key)) {
seenDimensions.add(key);
dimensionVariants.push({
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
thickness: parseInt(match[3], 10)
});
}
}
});
// Default dimensions (first variant or from legacy parsing)
let dimensions = dimensionVariants[0] || { width: null, height: null, thickness: null };
if (!dimensions.width) {
$('a, span, div').each((_, el) => {
const text = $(el).text();
if (text.match(/\d+\s*mm\s*x\s*\d+\s*mm\s*x\s*\d+\s*mm/i)) {
dimensions = parseDimensions(text);
return false;
}
});
}
// Get color/decor variants (from "Choisir la couleur" section)
const colorVariants = [];
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href') || '';
// Look for product links that are variants (not the current product)
if (href.match(/-p\d+$/) && !href.includes(productUrl.split('/').pop())) {
const variantUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
const variantName = $(el).attr('title') || $(el).text().trim();
const variantCode = extractDecorCode(variantName) || extractDecorCode(href);
// Look for variant thumbnail image
let variantImage = null;
const img = $(el).find('img').first();
if (img.length) {
variantImage = img.attr('src') || img.attr('data-src');
if (variantImage && !variantImage.startsWith('http')) {
variantImage = `${BASE_URL}${variantImage}`;
}
}
if (variantCode && variantName && !colorVariants.find(v => v.code === variantCode)) {
colorVariants.push({
code: variantCode,
name: variantName,
url: variantUrl,
imageUrl: variantImage
});
}
}
});
// Download main product image locally
let localImageUrl = null;
if (imageUrl && decorCode) {
localImageUrl = await downloadImage(imageUrl, `${decorCode}.jpg`);
}
// Download variant thumbnails
for (const variant of colorVariants) {
if (variant.imageUrl && variant.code) {
const localPath = await downloadImage(variant.imageUrl, `${variant.code}_thumb.jpg`);
if (localPath) {
variant.imageUrl = localPath;
}
}
}
// Extract a representative dominant color from the product name
const dominantColor = extractDominantColor(name);
return {
name,
decorCode,
dimensions,
dimensionVariants,
colorVariants,
category,
imageUrl: localImageUrl || imageUrl, // Prefer local path, fallback to URL
dominantColor
};
} catch (err) {
console.error(`Error fetching product details from ${productUrl}:`, err.message);
return null;
}
}
/**
* Get products from a collection page
* @param {string} collectionUrl
* @param {string} collectionName
* @param {Function} emit - Optional emitter for progress events
*/
async function getProductsFromCollection(collectionUrl, collectionName, emit = () => {}) {
const products = [];
const html = await fetchPage(collectionUrl);
const $ = cheerio.load(html);
// Find product links
const productLinks = new Set();
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
// Product pages contain -p followed by numbers
if (href && href.match(/-p\d+$/)) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
productLinks.add(fullUrl);
}
});
const productCount = productLinks.size;
console.log(` Found ${productCount} products in ${collectionName}`);
emit({ type: 'log', message: ` ${productCount} produits trouvés dans ${collectionName}` });
// Fetch each product's details
let productIndex = 0;
for (const productUrl of productLinks) {
await sleep(CRAWL_DELAY);
const details = await getProductDetails(productUrl);
productIndex++;
if (details && details.decorCode) {
products.push({
formex_code: details.decorCode,
name: details.name,
collection: collectionName,
category: details.category || collectionName,
width_mm: details.dimensions.width,
height_mm: details.dimensions.height,
thickness_mm: details.dimensions.thickness,
image_url: details.imageUrl,
formex_url: productUrl,
dominant_color: details.dominantColor,
dimensionVariants: details.dimensionVariants || [],
colorVariants: details.colorVariants || []
});
const dimCount = details.dimensionVariants?.length || 0;
const varCount = details.colorVariants?.length || 0;
emit({ type: 'log', message: ` [${productIndex}/${productCount}] ${details.name} (${dimCount} dims, ${varCount} variants)` });
}
}
return products;
}
/**
* Upsert a material into the database with enhanced data
*/
async function upsertMaterial(material) {
// Try to update first
const { rows } = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
let materialId;
if (rows.length > 0) {
materialId = rows[0].id;
// Update existing
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, dominant_color = COALESCE(?, dominant_color), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null, material.formex_code]
);
} else {
// Insert new
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, dominant_color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[material.formex_code, material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null]
);
// Get the inserted ID
const inserted = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
materialId = inserted.rows[0]?.id;
}
// Update dimension variants if we have a materialId
if (materialId && material.dimensionVariants && material.dimensionVariants.length > 0) {
// Delete existing dimensions
await run('DELETE FROM formex_dimensions WHERE material_id = ?', [materialId]);
// Insert new dimensions
for (let i = 0; i < material.dimensionVariants.length; i++) {
const dim = material.dimensionVariants[i];
await run(
`INSERT INTO formex_dimensions (material_id, width_mm, height_mm, thickness_mm, is_default)
VALUES (?, ?, ?, ?, ?)`,
[materialId, dim.width, dim.height, dim.thickness, i === 0 ? 1 : 0]
);
}
}
// Update color variants if we have a materialId
if (materialId && material.colorVariants && material.colorVariants.length > 0) {
// Delete existing variants
await run('DELETE FROM formex_variants WHERE material_id = ?', [materialId]);
// Insert new variants
for (const variant of material.colorVariants) {
await run(
`INSERT INTO formex_variants (material_id, variant_code, variant_name, variant_url, image_url)
VALUES (?, ?, ?, ?, ?)`,
[materialId, variant.code, variant.name, variant.url || null, variant.imageUrl || null]
);
}
}
return materialId;
}
/**
* Log sync result to database
*/
async function logSync(source, status, itemsCount, error = null) {
await run(
`INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)`,
[source, status, itemsCount, error]
);
}
/**
* Main sync function - crawls Formex and updates database
* @param {Function} emit - Optional emitter callback for SSE progress events
* Called with { type: 'log'|'progress'|'complete'|'error', ... }
*/
export async function sync(emit = () => {}) {
console.log('Starting Formex sync...');
emit({ type: 'log', message: 'Démarrage de la synchronisation Formex...' });
const startTime = Date.now();
let totalProducts = 0;
try {
// Get all collections
console.log('Fetching collections...');
emit({ type: 'log', message: 'Récupération des collections...' });
const collections = await getCollections();
const totalCollections = collections.length;
console.log(`Found ${totalCollections} collections`);
emit({ type: 'log', message: `${totalCollections} collections trouvées` });
emit({ type: 'progress', current: 0, total: totalCollections, collection: '' });
// Process each collection
for (let i = 0; i < collections.length; i++) {
const collection = collections[i];
console.log(`Processing: ${collection.name}`);
emit({ type: 'log', message: `Traitement: ${collection.name}` });
emit({ type: 'progress', current: i, total: totalCollections, collection: collection.name });
await sleep(CRAWL_DELAY);
try {
const products = await getProductsFromCollection(collection.url, collection.name, emit);
// Upsert each product
for (const product of products) {
await upsertMaterial(product);
totalProducts++;
}
emit({ type: 'log', message: ` ${products.length} produits enregistrés pour ${collection.name}` });
} catch (err) {
console.error(` Error processing ${collection.name}:`, err.message);
emit({ type: 'log', message: ` Erreur: ${err.message}` });
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`Sync complete: ${totalProducts} products in ${elapsed}s`);
await logSync('formex', 'success', totalProducts);
const result = {
success: true,
itemsCount: totalProducts,
duration: elapsed
};
emit({ type: 'progress', current: totalCollections, total: totalCollections, collection: '' });
emit({ type: 'complete', result });
return result;
} catch (err) {
console.error('Sync failed:', err.message);
await logSync('formex', 'error', totalProducts, err.message);
const result = {
success: false,
error: err.message,
itemsCount: totalProducts
};
emit({ type: 'error', error: err.message });
return result;
}
}
/**
* Get sync logs from database
*/
export async function getSyncLogs(limit = 20) {
const { rows } = await pool.query(
'SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT ?',
[limit]
);
return rows;
}
/**
* Get all scraped materials
*/
export async function getMaterials() {
const { rows } = await pool.query(
'SELECT * FROM formex_materials ORDER BY collection, name'
);
return rows;
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Layout Optimizer using OpenAI GPT-4
* Proposes optimal module arrangement within available width,
* respecting locked modules and optimizing for functionality and aesthetics
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt template for layout optimization
*/
const LAYOUT_OPTIMIZATION_PROMPT = `Tu es un expert en aménagement d'armoires sur mesure avec 20 ans d'expérience.
Propose un layout optimal pour les modules dans l'espace disponible.
CONTRAINTES:
- Largeur totale disponible: {totalWidth}mm
- Hauteur armoire: {height}mm
- Profondeur: {depth}mm
- Type d'installation: {installationType}
MODULES DISPONIBLES DANS LE CATALOGUE:
{availableModules}
MODULES ACTUELLEMENT PLACÉS:
{currentModules}
MODULES FIGÉS (ne pas déplacer, garder leur position exacte):
{lockedModules}
RÈGLES D'OPTIMISATION:
1. La somme des largeurs des modules doit égaler exactement la largeur totale
2. Les modules figés (locked) doivent rester à leur position exacte
3. Favoriser l'équilibre visuel (penderies aux extrémités, tiroirs au centre)
4. Regrouper les éléments de même type si possible
5. Pour une penderie principale, prévoir au moins 600mm de largeur
6. Les tiroirs sont plus pratiques à hauteur accessible (partie basse)
7. Laisser au moins un module rayonnage pour le linge plié
Retourne un JSON valide avec cette structure:
{
"layout": [
{
"moduleCode": "<code du module du catalogue>",
"moduleId": "<id unique du module dans la config, ou null si nouveau>",
"position": <index de position 0, 1, 2...>,
"widthMm": <largeur en mm>,
"locked": <true si figé, false sinon>,
"customizations": {
"shelves": <nombre d'étagères si personnalisable>,
"drawers": <nombre de tiroirs si applicable>,
"hasRod": <true/false pour tringle penderie>
}
}
],
"totalWidth": <somme des largeurs en mm>,
"reasoning": "<Explication en français du choix d'agencement>",
"alternatives": [
{
"description": "<description courte>",
"layout": [<même structure que layout principal>]
}
],
"warnings": ["<avertissements éventuels>"]
}
IMPORTANT: Retourne UNIQUEMENT du JSON valide, sans texte supplémentaire.`;
/**
* Build the prompt with actual data
*/
function buildPrompt(config, availableModules, lockedModuleIds = []) {
const currentModules = config.modules || [];
const lockedModules = currentModules.filter(m => lockedModuleIds.includes(m.id) || m.locked);
const unlockedModules = currentModules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const modulesCatalog = availableModules.map(m =>
`- ${m.code}: ${m.name} (${m.category}, largeur: ${m.width_mm}mm, ${m.shelves_count} étagères, ${m.drawers_count} tiroirs, penderie: ${m.has_rod ? 'oui' : 'non'}, prix: ${m.base_price_chf} CHF)`
).join('\n');
const currentModulesStr = currentModules.map((m, i) =>
`Position ${i}: ${m.moduleCode || 'module'} - ${m.widthMm}mm${m.locked ? ' [FIGÉ]' : ''}`
).join('\n') || 'Aucun module placé';
const lockedModulesStr = lockedModules.map((m, i) =>
`Position ${currentModules.indexOf(m)}: ${m.moduleCode || 'module'} - ${m.widthMm}mm`
).join('\n') || 'Aucun module figé';
return LAYOUT_OPTIMIZATION_PROMPT
.replace('{totalWidth}', config.widthMm || 2400)
.replace('{height}', config.heightMm || 2400)
.replace('{depth}', config.depthMm || 600)
.replace('{installationType}', config.installationType || 'free')
.replace('{availableModules}', modulesCatalog)
.replace('{currentModules}', currentModulesStr)
.replace('{lockedModules}', lockedModulesStr);
}
/**
* Optimize layout using GPT-4
* @param {Object} config - Current wardrobe configuration
* @param {Array} availableModules - List of available modules from catalog
* @param {Array} lockedModuleIds - IDs of modules that should not be moved
* @returns {Object} Optimized layout proposal
*/
export async function optimizeLayout(config, availableModules, lockedModuleIds = []) {
const openai = getOpenAIClient();
const prompt = buildPrompt(config, availableModules, lockedModuleIds);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const result = JSON.parse(content);
// Validate response structure
if (!result.layout || !Array.isArray(result.layout)) {
throw new Error('Invalid layout response structure');
}
// Validate total width matches
const totalLayoutWidth = result.layout.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalLayoutWidth - (config.widthMm || 2400)) > 10) {
result.warnings = result.warnings || [];
result.warnings.push(`La largeur totale (${totalLayoutWidth}mm) ne correspond pas exactement à la largeur demandée (${config.widthMm}mm)`);
}
return {
success: true,
...result,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Layout optimization error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Quick local optimization without AI (for simple cases)
* Distributes remaining space among unlocked modules
* @param {Object} config - Current configuration
* @param {Array} lockedModuleIds - IDs of locked modules
* @returns {Object} Optimized layout
*/
export function quickOptimize(config, lockedModuleIds = []) {
const modules = [...(config.modules || [])];
const totalWidth = config.widthMm || 2400;
// Calculate locked width
const lockedWidth = modules
.filter(m => lockedModuleIds.includes(m.id) || m.locked)
.reduce((sum, m) => sum + (m.widthMm || 0), 0);
// Get unlocked modules
const unlockedModules = modules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const remainingWidth = totalWidth - lockedWidth;
if (unlockedModules.length === 0) {
return { success: true, layout: modules, reasoning: 'Tous les modules sont figés' };
}
// Distribute remaining width equally among unlocked modules
const widthPerModule = Math.floor(remainingWidth / unlockedModules.length);
const remainder = remainingWidth - (widthPerModule * unlockedModules.length);
let extraAdded = 0;
const optimizedModules = modules.map(m => {
if (lockedModuleIds.includes(m.id) || m.locked) {
return m;
}
const extra = extraAdded < remainder ? 1 : 0;
extraAdded += extra;
return {
...m,
widthMm: widthPerModule + extra
};
});
return {
success: true,
layout: optimizedModules,
totalWidth: totalWidth,
reasoning: 'Répartition équitable de l\'espace entre les modules non-figés'
};
}
/**
* Suggest initial layout for empty configuration
* @param {number} totalWidth - Total available width in mm
* @param {Array} availableModules - Available module catalog
* @returns {Array} Suggested initial modules
*/
export function suggestInitialLayout(totalWidth, availableModules) {
const suggestions = [];
let remainingWidth = totalWidth;
// Standard layout: penderie - rayonnage - penderie for typical wardrobe
const penderie = availableModules.find(m => m.category === 'penderie' && m.width_mm <= remainingWidth / 3);
const rayonnage = availableModules.find(m => m.category === 'rayonnage');
if (penderie && rayonnage) {
// Two penderies on sides
const penderieWidth = Math.min(penderie.width_mm, Math.floor(totalWidth * 0.3));
const rayonnageWidth = totalWidth - (penderieWidth * 2);
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 0,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
suggestions.push({
moduleCode: rayonnage.code,
moduleTypeId: rayonnage.id,
widthMm: rayonnageWidth,
position: 1,
locked: false,
shelves: rayonnage.shelves_count,
drawers: 0,
hasRod: false
});
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 2,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
}
return suggestions;
}
export default { optimizeLayout, quickOptimize, suggestInitialLayout };
+358
View File
@@ -0,0 +1,358 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
*/
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
}
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate a dimension line with arrows and text
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
// Label (rotated)
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
/**
* Generate all plans for a configuration
*/
export function generateAllPlans(config, modules = [], colors = {}) {
return {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
metadata: {
generatedAt: new Date().toISOString(),
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
}
}
};
}
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
.page { page-break-after: always; padding: 20px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.title { font-size: 20px; font-weight: bold; margin: 0; }
.subtitle { font-size: 12px; color: #666; margin: 5px 0 0; }
.plan-container { margin: 20px 0; text-align: center; }
.plan-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
.summary { background: #f0f7ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
<h3>Spécifications</h3>
<div class="specs">
<div class="spec-item"><span>Largeur:</span><strong>${config.widthMm} mm</strong></div>
<div class="spec-item"><span>Hauteur:</span><strong>${config.heightMm} mm</strong></div>
<div class="spec-item"><span>Profondeur:</span><strong>${config.depthMm} mm</strong></div>
<div class="spec-item"><span>Plinthe:</span><strong>${config.plinthHeightMm} mm</strong></div>
<div class="spec-item"><span>Type:</span><strong>${config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</strong></div>
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Vue de côté</div>
${plans.side}
</div>
<div class="plan-container">
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
<div class="page">
<h2>Liste de débitage</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
for (const group of cuttingList.byFinish) {
html += `
<h3>Finition: ${group.finishLabel}</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
html += `
<tr>
<td>${piece.description}</td>
<td>${piece.widthMm} mm</td>
<td>${piece.heightMm} mm</td>
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateAllPlans,
generatePDFHtml
};
+422
View File
@@ -0,0 +1,422 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
*/
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
// Portes (surface face visible)
if (doorType === 'sliding' && slidingPanelsCount) {
const panelWidth = widthMm / slidingPanelsCount;
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
} else {
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
const doorWidth = widthMm / Math.max(1, doorsCount);
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
}
// Plinthe (face avant)
surface += widthMm * plinthHeightMm;
// Bandeau et fileurs si niche
if (isNiche) {
surface += widthMm * plinthHeightMm; // bandeau haut
surface += 2 * plinthHeightMm * (heightMm - plinthHeightMm); // fileurs gauche/droite
}
// Côtés de doublage (coulissant)
if (doorType === 'sliding') {
surface += 2 * (depthMm * (heightMm - plinthHeightMm));
}
return surface / 1_000_000; // m²
}
/**
* Nombre de panneaux entiers nécessaires (arrondi supérieur).
* Coût = nombre_panneaux × prix_panneau.
*/
function panelCost(surfaceM2, panelWidthMm, panelHeightMm, pricePerPanelChf) {
const panelSurfaceM2 = (panelWidthMm * panelHeightMm) / 1_000_000;
const count = Math.ceil(surfaceM2 / panelSurfaceM2);
return { panels: Math.max(0, count), costChf: count * pricePerPanelChf };
}
/**
* Find the optimal dimension variant that minimizes cost (fewest panels needed).
* @param {number} surfaceM2 - Required surface in square meters
* @param {Array} dimensions - Array of dimension variants [{width_mm, height_mm, thickness_mm, id}]
* @param {number} pricePerPanel - Price per panel in CHF
* @returns {Object} - Optimal dimension with cost details and alternatives
*/
function findOptimalDimension(surfaceM2, dimensions, pricePerPanel) {
if (!dimensions || dimensions.length === 0) {
return null;
}
const price = pricePerPanel || 0;
const results = [];
for (const dim of dimensions) {
if (!dim.width_mm || !dim.height_mm) continue;
const panelSurfaceM2 = (dim.width_mm * dim.height_mm) / 1_000_000;
const panelsNeeded = Math.ceil(surfaceM2 / panelSurfaceM2);
const totalCost = panelsNeeded * price;
const totalSurface = panelsNeeded * panelSurfaceM2;
const wasteM2 = totalSurface - surfaceM2;
const wastePercent = (wasteM2 / totalSurface) * 100;
results.push({
dimension: {
id: dim.id,
width_mm: dim.width_mm,
height_mm: dim.height_mm,
thickness_mm: dim.thickness_mm
},
panelsNeeded,
costChf: totalCost,
panelSurfaceM2,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round(wastePercent * 10) / 10
});
}
// Sort by cost (lowest first), then by waste (lowest first)
results.sort((a, b) => {
if (a.costChf !== b.costChf) return a.costChf - b.costChf;
return a.wasteM2 - b.wasteM2;
});
if (results.length === 0) return null;
const optimal = results[0];
const alternatives = results.slice(1);
return {
selectedDimension: optimal.dimension,
panelsNeeded: optimal.panelsNeeded,
costChf: optimal.costChf,
wasteM2: optimal.wasteM2,
wastePercent: optimal.wastePercent,
alternatives: alternatives.map(alt => ({
dimension: alt.dimension,
panelsNeeded: alt.panelsNeeded,
costChf: alt.costChf,
wasteM2: alt.wasteM2,
wastePercent: alt.wastePercent
}))
};
}
/**
* Calculate module costs (V2 mode)
* @param {Array} modules - Array of module configurations
* @param {Array} moduleCatalog - Available modules from database
* @returns {Object} - Module costs breakdown
*/
function calculateModuleCosts(modules, moduleCatalog) {
let totalChf = 0;
const breakdown = [];
for (const module of modules) {
const catalogModule = moduleCatalog.find(m => m.id === module.moduleTypeId || m.code === module.moduleCode);
const basePrice = catalogModule?.base_price_chf || 450;
// Adjust price based on customizations
let adjustedPrice = basePrice;
// Extra shelves cost (beyond default)
const defaultShelves = catalogModule?.shelves_count || 0;
const extraShelves = Math.max(0, (module.shelves || 0) - defaultShelves);
adjustedPrice += extraShelves * 25; // 25 CHF per extra shelf
// Extra drawers cost (beyond default)
const defaultDrawers = catalogModule?.drawers_count || 0;
const extraDrawers = Math.max(0, (module.drawers || 0) - defaultDrawers);
adjustedPrice += extraDrawers * 45; // 45 CHF per extra drawer
// Width adjustment (if different from default)
const defaultWidth = catalogModule?.width_mm || 600;
const widthDiff = (module.widthMm || defaultWidth) - defaultWidth;
if (widthDiff > 0) {
adjustedPrice += widthDiff * 0.15; // 0.15 CHF per extra mm
}
breakdown.push({
moduleCode: module.moduleCode || catalogModule?.code,
moduleName: catalogModule?.name || 'Module',
basePrice,
adjustedPrice: Math.round(adjustedPrice * 100) / 100,
adjustments: {
extraShelves,
extraDrawers,
widthDiff
}
});
totalChf += adjustedPrice;
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Calculate hardware costs (V2)
* @param {Object} hardware - Hardware selection {handleVariantId, hingeVariantId, drawerSystemId}
* @param {Object} hardwareData - Hardware data from DB {handles, hinges, blum}
* @param {Object} config - Config with door and drawer counts
* @returns {Object} - Hardware costs breakdown
*/
function calculateHardwareCosts(hardware, hardwareData, config) {
const { handles = [], hinges = [], blum = [] } = hardwareData;
const breakdown = [];
let totalChf = 0;
// Handle costs - one per door
if (hardware.handleVariantId) {
const handle = handles.find(h => h.id === hardware.handleVariantId);
if (handle) {
const doorCount = config.doorType === 'sliding'
? (config.slidingPanelsCount || 2)
: (config.modules?.length || config.blocks?.length || 2);
const cost = handle.price_chf * doorCount;
totalChf += cost;
breakdown.push({
type: 'handle',
name: handle.name,
unitPrice: handle.price_chf,
quantity: doorCount,
totalChf: cost
});
}
}
// Hinge costs - typically 3 per hinged door
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2;
const hingesPerDoor = 3;
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
breakdown.push({
type: 'hinge',
name: hinge.name,
unitPrice: hinge.price_chf,
quantity,
totalChf: cost
});
}
}
// Drawer system costs - one per drawer
if (hardware.drawerSystemId) {
const system = blum.find(h => h.id === hardware.drawerSystemId && h.type === 'drawer_system');
if (system) {
const modules = config.modules || [];
const drawerCount = modules.reduce((sum, m) => sum + (m.drawers || 0), 0);
if (drawerCount > 0) {
const cost = system.price_chf * drawerCount;
totalChf += cost;
breakdown.push({
type: 'drawer_system',
name: system.name,
unitPrice: system.price_chf,
quantity: drawerCount,
totalChf: cost
});
}
}
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Valide la configuration et calcule le prix total.
* @param {Object} config - configuration client
* @param {Object} data - données DB (settings, blockTypes, finishes, serviceOptions, modules, hardware)
*/
export function calculatePrice(config, data) {
const errors = [];
const settingsArr = data.settings || [];
const settings = {};
for (const r of settingsArr) {
let v = r.value;
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
settings[r.key] = v;
}
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
} = config;
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
// Plinthe 60–160 mm
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
}
// Portes coulissantes : largeur totale >= 1200 mm
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
if (useModules) {
// V2: Use module-based pricing
const moduleCatalog = data.modules || [];
const moduleCosts = calculateModuleCosts(modules, moduleCatalog);
blocksChf = moduleCosts.totalChf;
modulesBreakdown = moduleCosts.breakdown;
// Validate module widths
const totalModuleWidth = modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalModuleWidth - widthMm) > 10) {
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
if (Math.abs(totalWidth - widthMm) > 5 && blocks.length > 0) {
errors.push({ field: 'blocks', message: 'La somme des largeurs des blocs doit correspondre à la largeur totale.' });
}
const blockTypes = data.blockTypes || [];
for (const block of blocks) {
const bt = blockTypes.find((b) => b.id === block.blockTypeId || b.code === block.blockTypeCode);
if (bt) blocksChf += Number(bt.base_price_chf) || 550;
else blocksChf += 550;
}
}
// Calculate hardware costs (V2)
let hardwareChf = 0;
let hardwareBreakdown = null;
if (hardware && (hardware.handleVariantId || hardware.hingeVariantId || hardware.drawerSystemId)) {
const hardwareData = {
handles: data.handles || [],
hinges: data.hinges || [],
blum: data.blumHardware || []
};
const hardwareCosts = calculateHardwareCosts(hardware, hardwareData, config);
hardwareChf = hardwareCosts.totalChf;
hardwareBreakdown = hardwareCosts.breakdown;
}
let panelsChf = 0;
let panelOptimization = null;
const surfaceM2 = calculateVisibleSurface({
...config,
widthMm: widthMm || blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0),
plinthHeightMm: plinthHeightMm || 100,
blocks: useModules ? modules.map(m => ({ widthMm: m.widthMm })) : blocks,
doorType: doorType || 'hinged',
slidingPanelsCount: slidingPanelsCount || 2
});
// Find finish/material by ID or code
const finish = (data.finishes || []).find((f) => f.id === finishId || f.code === config.finishCode);
if (finish) {
const priceChf = finish.price_chf;
// Check if we have multiple dimension variants for optimization
if (finish.dimensions && finish.dimensions.length > 0 && priceChf != null) {
// Auto-optimize: find the best dimension that minimizes cost
panelOptimization = findOptimalDimension(surfaceM2, finish.dimensions, Number(priceChf));
if (panelOptimization) {
panelsChf = panelOptimization.costChf;
}
} else if (finish.width_mm && finish.height_mm && priceChf != null) {
// Legacy: single dimension from finish
const result = panelCost(surfaceM2, finish.width_mm, finish.height_mm, Number(priceChf));
panelsChf = result.costChf;
// Create optimization info for legacy finishes
const panelSurfaceM2 = (finish.width_mm * finish.height_mm) / 1_000_000;
const wasteM2 = (result.panels * panelSurfaceM2) - surfaceM2;
panelOptimization = {
selectedDimension: {
width_mm: finish.width_mm,
height_mm: finish.height_mm,
thickness_mm: null
},
panelsNeeded: result.panels,
costChf: result.costChf,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round((wasteM2 / (result.panels * panelSurfaceM2)) * 1000) / 10,
alternatives: []
};
}
}
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
const opt = services.find((s) => s.id === optId || s.code === optId);
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
surfaceM2: Math.round(surfaceM2 * 1000) / 1000,
details: {
blocksChf,
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
errors: errors.length ? errors : undefined
};
}
export default {
calculatePrice,
calculateVisibleSurface,
panelCost,
findOptimalDimension,
calculateModuleCosts,
calculateHardwareCosts
};
+245
View File
@@ -0,0 +1,245 @@
/**
* Room Photo Analyzer using OpenAI GPT-4 Vision
* Analyzes room photos to extract dimensions, detect doors/windows, and suggest wardrobe placement
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt for GPT-4 Vision to analyze room photos
*/
const ROOM_ANALYSIS_PROMPT = `You are a professional interior designer and architect specializing in custom wardrobe installations. Analyze this room photo with a STRONG FOCUS on the visible wall surfaces (parois) and ceiling (plafond).
PRIORITY: Carefully examine every visible wall and the ceiling. Detect their exact colors, materials, and textures as they appear in the photo. This is critical for accurate 3D room reconstruction.
Return a JSON object with the following structure:
{
"roomDimensions": {
"widthM": <estimated width in meters, e.g., 4.2>,
"lengthM": <estimated length/depth in meters, e.g., 3.8>,
"heightM": <estimated ceiling height in meters, typically 2.4-2.7>
},
"walls": [
{
"side": "back" | "left" | "right",
"visible": <boolean - is this wall clearly visible in the photo?>,
"color": "<EXACT hex color code sampled from the wall surface, e.g., #f5f5f0>",
"material": "paint" | "wallpaper" | "brick" | "wood_panel" | "concrete" | "plaster" | "stone" | "tile",
"texture": "smooth" | "textured" | "patterned" | "rough" | "glossy",
"hasWindow": <boolean>,
"hasDoor": <boolean>,
"surfaceArea": <approximate visible surface area percentage 0-100, accounting for windows/doors>
}
],
"windows": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"yPercent": <vertical position from floor, 0-100>,
"widthPercent": <width as percentage of wall, e.g., 25>,
"heightPercent": <height as percentage of wall, e.g., 40>,
"type": "window" | "french_window" | "skylight" | "bay_window"
}
],
"doors": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"widthPercent": <width as percentage of wall, typically 15-25>,
"heightPercent": <height as percentage of wall, typically 80-90>,
"type": "door" | "double_door" | "opening" | "closet" | "sliding_door"
}
],
"floor": {
"color": "<exact hex color code>",
"material": "wood" | "tile" | "carpet" | "concrete" | "laminate" | "vinyl" | "parquet"
},
"ceiling": {
"visible": <boolean - is the ceiling visible in the photo?>,
"color": "<EXACT hex color code detected from the ceiling surface>",
"type": "flat" | "sloped" | "beamed" | "coffered" | "vaulted",
"material": "paint" | "plaster" | "wood" | "concrete",
"slopeAngleDeg": <angle in degrees if sloped, 0 for flat, null if not applicable>,
"slopeDirection": "front_to_back" | "left_to_right" | null
},
"lighting": {
"natural": "bright" | "moderate" | "dim",
"windowSide": "left" | "right" | "back" | "front" | null
},
"suggestedWardrobeWall": "back" | "left" | "right",
"suggestedWardrobePosition": {
"xPercent": <start position from left, 0-100>,
"widthPercent": <suggested width as percentage of wall, e.g., 50>,
"reason": "<brief explanation of why this position is recommended>"
},
"roomType": "bedroom" | "living_room" | "hallway" | "dressing" | "other",
"confidence": <confidence score 0-100>
}
IMPORTANT GUIDELINES FOR WALL AND CEILING DETECTION:
- ALWAYS include all three walls (back, left, right). Set visible=false only if a wall is truly not visible at all.
- Sample the EXACT hex color from the dominant area of each wall surface — do NOT default to #f5f5f0.
- Pay close attention to paint color nuances: beige vs cream vs off-white vs warm grey etc.
- Detect the wall material: painted plaster, wallpaper, exposed brick, wood paneling, etc.
- For the ceiling: detect its actual color (it may not be pure white), its type, and material.
- If the ceiling is sloped (mansarde/sous-pente), estimate the slope angle and direction.
- Estimate dimensions based on standard furniture sizes if visible (beds, doors, windows).
- Standard door height is ~2.1m, standard door width is ~0.8-0.9m.
- Standard ceiling height is 2.4-2.7m in residential.
- For wardrobe placement, prefer walls without windows/doors and with enough clear space.
- Return ONLY valid JSON, no additional text.`;
/**
* Analyze a room photo using GPT-4 Vision
* @param {string} imageBase64 - Base64 encoded image data
* @param {string} mimeType - Image MIME type (image/jpeg, image/png, etc.)
* @returns {Object} Room analysis result
*/
export async function analyzeRoomPhoto(imageBase64, mimeType = 'image/jpeg') {
const openai = getOpenAIClient();
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: ROOM_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
detail: 'high'
}
}
]
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const analysis = JSON.parse(content);
// Validate required fields
if (!analysis.roomDimensions || !analysis.suggestedWardrobeWall) {
throw new Error('Incomplete room analysis');
}
return {
success: true,
analysis,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Room analysis error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Convert analysis to 3D room configuration for Three.js
* @param {Object} analysis - Room analysis from GPT-4 Vision
* @returns {Object} 3D room configuration
*/
export function analysisTo3DConfig(analysis) {
const { roomDimensions, walls, windows, doors, floor, ceiling, suggestedWardrobeWall, suggestedWardrobePosition } = analysis;
return {
// Room dimensions in meters
width: roomDimensions.widthM || 4,
length: roomDimensions.lengthM || 4,
height: roomDimensions.heightM || 2.5,
// Wall configurations with material info
walls: {
back: (() => {
const w = walls?.find(w => w.side === 'back');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
left: (() => {
const w = walls?.find(w => w.side === 'left');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
right: (() => {
const w = walls?.find(w => w.side === 'right');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})()
},
// Windows for each wall
windows: (windows || []).map(w => ({
wall: w.wall,
x: (w.xPercent || 50) / 100,
y: (w.yPercent || 30) / 100,
width: (w.widthPercent || 25) / 100,
height: (w.heightPercent || 40) / 100,
type: w.type || 'window'
})),
// Doors for each wall
doors: (doors || []).map(d => ({
wall: d.wall,
x: (d.xPercent || 10) / 100,
width: (d.widthPercent || 20) / 100,
height: (d.heightPercent || 85) / 100,
type: d.type || 'door'
})),
// Floor and ceiling
floor: {
color: floor?.color || '#c4a574',
material: floor?.material || 'wood'
},
ceiling: {
color: ceiling?.color || '#ffffff',
type: ceiling?.type || 'flat',
visible: ceiling?.visible !== false,
material: ceiling?.material || 'paint',
slopeAngleDeg: ceiling?.slopeAngleDeg || 0,
slopeDirection: ceiling?.slopeDirection || null
},
// Wardrobe placement
wardrobePlacement: {
wall: suggestedWardrobeWall || 'back',
xPercent: suggestedWardrobePosition?.xPercent || 25,
widthPercent: suggestedWardrobePosition?.widthPercent || 50
}
};
}
export default { analyzeRoomPhoto, analysisTo3DConfig };
+60
View File
@@ -0,0 +1,60 @@
/**
* Scheduler service using node-cron
* Handles scheduled tasks like Formex sync
*/
import cron from 'node-cron';
import { sync as formexSync } from './formexCrawler.js';
let formexJob = null;
/**
* Initialize the scheduler with configured jobs
*/
export function initScheduler() {
const cronExpression = process.env.FORMEX_SYNC_CRON || '0 3 * * *'; // Default: 3:00 AM daily
// Validate cron expression
if (!cron.validate(cronExpression)) {
console.error(`Invalid FORMEX_SYNC_CRON expression: ${cronExpression}`);
return;
}
// Schedule Formex sync
formexJob = cron.schedule(cronExpression, async () => {
console.log(`[${new Date().toISOString()}] Running scheduled Formex sync...`);
try {
const result = await formexSync();
console.log(`[${new Date().toISOString()}] Scheduled sync complete:`, result);
} catch (err) {
console.error(`[${new Date().toISOString()}] Scheduled sync failed:`, err.message);
}
}, {
scheduled: true,
timezone: 'Europe/Zurich' // Swiss timezone
});
console.log(`Formex sync scheduled: ${cronExpression} (Europe/Zurich)`);
}
/**
* Stop all scheduled jobs
*/
export function stopScheduler() {
if (formexJob) {
formexJob.stop();
formexJob = null;
console.log('Scheduler stopped');
}
}
/**
* Get scheduler status
*/
export function getSchedulerStatus() {
return {
formexSync: {
running: formexJob !== null,
schedule: process.env.FORMEX_SYNC_CRON || '0 3 * * *'
}
};
}