v1.0
This commit is contained in:
@@ -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 };
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 * * *'
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user