This commit is contained in:
lejay
2026-02-13 00:21:53 +01:00
parent 81d2412835
commit 6923c319a1
85 changed files with 17836 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Chemin du fichier SQLite (optionnel, défaut : server/data/configurateur.db)
# DATABASE_PATH=./data/configurateur.db
PORT=3001
# Formex sync schedule (cron expression, default: 3:00 AM daily)
#FORMEX_SYNC_CRON=0 3 * * *
# Delay between requests in ms (to avoid rate limiting)
#FORMEX_CRAWL_DELAY_MS=500
# OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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
+62
View File
@@ -0,0 +1,62 @@
import initSqlJs from 'sql.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
let db = null;
function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?');
}
function save() {
if (db && dbPath) {
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
}
}
async function getDb() {
if (db) return db;
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
let buffer;
if (fs.existsSync(dbPath)) {
buffer = fs.readFileSync(dbPath);
}
db = new SQL.Database(buffer || null);
return db;
}
export const dbReady = getDb();
export const query = async (sql, params = []) => {
const database = await getDb();
const sqliteSql = toSqlitePlaceholders(sql);
const stmt = database.prepare(sqliteSql);
stmt.bind(Array.isArray(params) ? params : []);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
return { rows };
};
export const run = async (sql, params = []) => {
const database = await getDb();
const sqliteSql = toSqlitePlaceholders(sql);
database.run(sqliteSql, Array.isArray(params) ? params : []);
save();
return {};
};
const pool = {
query: (sql, params) => query(sql, params)
};
export default pool;
+19
View File
@@ -0,0 +1,19 @@
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const db = new SQL.Database();
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Schema applied.');
+56
View File
@@ -0,0 +1,56 @@
/**
* Run pending migrations on an existing database
*/
import initSqlJs from 'sql.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const migrationsDir = path.join(__dirname, 'migrations');
async function migrate() {
if (!fs.existsSync(dbPath)) {
console.error(`Database not found at ${dbPath}. Run db:init first.`);
process.exit(1);
}
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer);
// Get migration files
const migrationFiles = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
console.log(`Found ${migrationFiles.length} migration(s)`);
for (const file of migrationFiles) {
console.log(`Running migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
try {
db.exec(sql);
console.log(` ✓ ${file} applied`);
} catch (err) {
// IF NOT EXISTS should prevent errors on re-run
console.log(` ✓ ${file} (already applied or no changes)`);
}
}
// Save database
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Migrations complete.');
}
migrate().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});
@@ -0,0 +1,25 @@
-- Migration: Add Formex materials and sync logs tables
-- Run this on existing databases to add crawler support
-- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL,
width_mm INTEGER,
height_mm INTEGER,
thickness_mm INTEGER,
formex_url TEXT,
last_synced_at TEXT DEFAULT (datetime('now'))
);
-- Journal de synchronisation
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
status TEXT NOT NULL,
items_count INTEGER,
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -0,0 +1,34 @@
-- Migration: Enhanced Formex materials with dimensions, variants, images
-- Adds support for multiple dimension formats and color variants per product
-- Add new columns to formex_materials
ALTER TABLE formex_materials ADD COLUMN category TEXT;
ALTER TABLE formex_materials ADD COLUMN image_url TEXT;
ALTER TABLE formex_materials ADD COLUMN price_chf REAL;
ALTER TABLE formex_materials ADD COLUMN active INTEGER DEFAULT 1;
-- Dimension variants (multiple dimensions per material)
CREATE TABLE IF NOT EXISTS formex_dimensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
is_default INTEGER DEFAULT 0
);
-- Color/decor variants (linked products)
CREATE TABLE IF NOT EXISTS formex_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
variant_code TEXT NOT NULL,
variant_name TEXT NOT NULL,
variant_url TEXT,
image_url TEXT
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
+12
View File
@@ -0,0 +1,12 @@
-- Migration: Room configurations table for AI-analyzed room layouts
-- Stores room analysis results linked to orders
CREATE TABLE IF NOT EXISTS room_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id),
image_path TEXT,
analysis_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
+72
View File
@@ -0,0 +1,72 @@
-- Migration: Modules, hardware, and variants for V2 configurator
-- Catalogue de modules standards
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
width_mm INTEGER NOT NULL,
height_mm INTEGER,
depth_mm INTEGER,
shelves_count INTEGER DEFAULT 0,
drawers_count INTEGER DEFAULT 0,
has_rod INTEGER DEFAULT 0, -- penderie
customizable INTEGER DEFAULT 1,
base_price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Quincaillerie Blum
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes poignées
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes charnières
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'vissee', 'plantee'
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
-- Configuration couleurs par élément (pour sauvegarder les configs)
CREATE TABLE IF NOT EXISTS element_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
finish_id INTEGER NOT NULL,
finish_label TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
@@ -0,0 +1,5 @@
-- Add dominant_color column to formex_materials for 3D preview color representation
ALTER TABLE formex_materials ADD COLUMN dominant_color TEXT;
-- Create index for quick lookups
CREATE INDEX IF NOT EXISTS idx_formex_materials_dominant_color ON formex_materials(dominant_color);
+202
View File
@@ -0,0 +1,202 @@
-- Configurateur armoires sur mesure — Schéma V1 (SQLite)
-- Paramètres globaux (forfait kit, plinthe, épaisseur panneaux, etc.)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Produits / gammes (ex. SIGNATURE)
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Types de blocs (penderie, rayonnage, mixte vertical, mixte horizontal)
CREATE TABLE IF NOT EXISTS block_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
base_price_chf REAL NOT NULL,
width_min_mm INTEGER NOT NULL,
width_max_mm INTEGER NOT NULL,
active INTEGER DEFAULT 1
);
-- Formats de panneaux (par finition : dimensions + prix panneau entier)
CREATE TABLE IF NOT EXISTS panel_formats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
price_chf REAL NOT NULL
);
-- Finitions visibles (stratifié décor, couleur, effet bois, etc.)
CREATE TABLE IF NOT EXISTS finishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
panel_format_id INTEGER NOT NULL REFERENCES panel_formats(id),
active INTEGER DEFAULT 1
);
-- Options de service (prise de mesure, montage)
CREATE TABLE IF NOT EXISTS service_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
price_chf REAL NOT NULL,
price_type TEXT NOT NULL CHECK (price_type IN ('fixed', 'per_hour', 'per_block')),
active INTEGER DEFAULT 1
);
-- Commandes / leads (config snapshot + coordonnées)
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config TEXT NOT NULL,
total_chf REAL,
email TEXT,
name TEXT,
phone TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL,
category TEXT,
width_mm INTEGER,
height_mm INTEGER,
thickness_mm INTEGER,
image_url TEXT,
formex_url TEXT,
price_chf REAL,
dominant_color TEXT,
active INTEGER DEFAULT 1,
last_synced_at TEXT DEFAULT (datetime('now'))
);
-- Dimension variants (multiple dimensions per material)
CREATE TABLE IF NOT EXISTS formex_dimensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
is_default INTEGER DEFAULT 0
);
-- Color/decor variants (linked products)
CREATE TABLE IF NOT EXISTS formex_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
variant_code TEXT NOT NULL,
variant_name TEXT NOT NULL,
variant_url TEXT,
image_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
-- Journal de synchronisation
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
status TEXT NOT NULL,
items_count INTEGER,
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Room configurations (AI-analyzed room layouts)
CREATE TABLE IF NOT EXISTS room_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id),
image_path TEXT,
analysis_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
-- Catalogue de modules standards
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
width_mm INTEGER NOT NULL,
height_mm INTEGER,
depth_mm INTEGER,
shelves_count INTEGER DEFAULT 0,
drawers_count INTEGER DEFAULT 0,
has_rod INTEGER DEFAULT 0, -- penderie
customizable INTEGER DEFAULT 1,
base_price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Quincaillerie Blum
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes poignées
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes charnières
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'vissee', 'plantee'
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
-- Configuration couleurs par élément (pour sauvegarder les configs)
CREATE TABLE IF NOT EXISTS element_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
finish_id INTEGER NOT NULL,
finish_label TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
+23
View File
@@ -0,0 +1,23 @@
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '..', 'data', 'configurateur.db');
if (!fs.existsSync(dbPath)) {
console.error('Database file not found. Run npm run db:init first.');
process.exit(1);
}
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer);
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
db.exec(seedSql);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Seeds applied.');
+75
View File
@@ -0,0 +1,75 @@
-- Données initiales V1 (SQLite)
INSERT OR REPLACE INTO settings (key, value) VALUES
('kit_preparation_fee_chf', '350'),
('panel_thickness_mm', '19'),
('plinth_height_min_mm', '60'),
('plinth_height_max_mm', '160'),
('niche_clearance_multiplier', '2'),
('min_clearance_mm', '20'),
('sliding_doors_min_width_mm', '1200');
INSERT OR IGNORE INTO products (code, label, active) VALUES
('SIGNATURE', 'Armoire SIGNATURE', 1);
INSERT OR IGNORE INTO block_types (code, label, base_price_chf, width_min_mm, width_max_mm) VALUES
('penderie', 'Penderie', 550, 400, 1200),
('rayonnage', 'Rayonnage', 550, 400, 1200),
('mixte_vertical', 'Mixte vertical', 550, 400, 1200),
('mixte_horizontal', 'Mixte horizontal', 550, 400, 1200);
INSERT INTO panel_formats (name, width_mm, height_mm, price_chf) VALUES
('Stratifié standard', 2800, 2070, 85),
('Stratifié couleur', 2800, 2070, 95),
('Stratifié effet bois', 2800, 2070, 110),
('Stratifié vieux bois', 2800, 2070, 120);
INSERT OR IGNORE INTO finishes (code, label, panel_format_id) VALUES
('stratifie_decor', 'Stratifié décor', (SELECT id FROM panel_formats WHERE name = 'Stratifié standard' LIMIT 1)),
('stratifie_couleur', 'Stratifié couleur', (SELECT id FROM panel_formats WHERE name = 'Stratifié couleur' LIMIT 1)),
('stratifie_effet_bois', 'Stratifié effet bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié effet bois' LIMIT 1)),
('stratifie_vieux_bois', 'Stratifié vieux bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié vieux bois' LIMIT 1));
INSERT OR IGNORE INTO service_options (code, label, price_chf, price_type) VALUES
('prise_mesure', 'Prise de mesure', 95, 'per_hour'),
('montage', 'Montage', 95, 'per_hour');
-- Modules standards V2
INSERT OR IGNORE INTO modules (code, name, category, width_mm, height_mm, depth_mm, shelves_count, drawers_count, has_rod, customizable, base_price_chf) VALUES
('penderie_simple', 'Penderie simple', 'penderie', 600, NULL, 580, 1, 0, 1, 1, 450),
('penderie_double', 'Penderie double', 'penderie', 900, NULL, 580, 2, 0, 1, 1, 550),
('penderie_large', 'Penderie large', 'penderie', 1200, NULL, 580, 2, 0, 1, 1, 650),
('rayonnage_4', 'Rayonnage 4 étagères', 'rayonnage', 600, NULL, 580, 4, 0, 0, 1, 380),
('rayonnage_5', 'Rayonnage 5 étagères', 'rayonnage', 600, NULL, 580, 5, 0, 0, 1, 420),
('rayonnage_6', 'Rayonnage 6 étagères', 'rayonnage', 600, NULL, 580, 6, 0, 0, 1, 460),
('tiroirs_3', 'Bloc 3 tiroirs', 'tiroirs', 600, 600, 580, 0, 3, 0, 0, 520),
('tiroirs_4', 'Bloc 4 tiroirs', 'tiroirs', 600, 800, 580, 0, 4, 0, 0, 620),
('tiroirs_5', 'Bloc 5 tiroirs', 'tiroirs', 600, 1000, 580, 0, 5, 0, 0, 720),
('mixte_penderie_tiroirs', 'Penderie + tiroirs', 'mixte', 600, NULL, 580, 0, 2, 1, 1, 580),
('mixte_rayonnage_tiroirs', 'Rayonnage + tiroirs', 'mixte', 600, NULL, 580, 3, 2, 0, 1, 550),
('mixte_complet', 'Module complet', 'mixte', 900, NULL, 580, 2, 2, 1, 1, 720);
-- Quincaillerie Blum
INSERT OR IGNORE INTO blum_hardware (code, name, type, variant, price_chf, specs_json) VALUES
('legrabox_pure', 'LEGRABOX pure', 'drawer_system', 'legrabox', 85, '{"height_mm": 177, "load_kg": 70, "soft_close": true}'),
('legrabox_free', 'LEGRABOX free', 'drawer_system', 'legrabox', 95, '{"height_mm": 177, "load_kg": 70, "soft_close": true, "tip_on": true}'),
('tandembox_antaro', 'TANDEMBOX antaro', 'drawer_system', 'tandembox', 65, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
('tandembox_intivo', 'TANDEMBOX intivo', 'drawer_system', 'tandembox', 75, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
('movento_runner', 'MOVENTO coulisse', 'runner', 'movento', 45, '{"load_kg": 60, "soft_close": true}'),
('clip_top_soft', 'CLIP top BLUMOTION', 'hinge', 'clip_top', 12, '{"opening_angle": 110, "soft_close": true}'),
('clip_top_standard', 'CLIP top Standard', 'hinge', 'clip_top', 8, '{"opening_angle": 110, "soft_close": false}'),
('aventos_hf', 'AVENTOS HF', 'lift_system', 'aventos', 180, '{"type": "bi-fold", "soft_close": true}'),
('aventos_hl', 'AVENTOS HL', 'lift_system', 'aventos', 150, '{"type": "lift_up", "soft_close": true}');
-- Variantes poignées
INSERT OR IGNORE INTO handle_variants (code, name, style, length_mm, price_chf) VALUES
('barre_moderne', 'Barre moderne', 'moderne', 160, 25),
('barre_longue', 'Barre longue', 'moderne', 320, 35),
('bouton_rond', 'Bouton rond', 'classique', 35, 15),
('coquille', 'Coquille encastrée', 'minimaliste', 100, 28),
('profil_alu', 'Profil aluminium', 'minimaliste', NULL, 45);
-- Variantes charnières
INSERT OR IGNORE INTO hinge_variants (code, name, type, brand, price_chf) VALUES
('blum_vissee', 'Blum vissée', 'vissee', 'Blum', 12),
('blum_plantee', 'Blum plantée (INSERTA)', 'plantee', 'Blum', 18);
+163
View File
@@ -0,0 +1,163 @@
import dotenv from 'dotenv';
import express from 'express';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import cors from 'cors';
import { dbReady } from './db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.join(__dirname, '.env') });
import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js';
import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings } from './routes/settings.js';
import { postPrice } from './routes/price.js';
import { getOrders, postOrder } from './routes/orders.js';
import {
triggerFormexSync,
streamFormexSync,
getLogs,
getStatus,
getFormexMaterials,
getFormexMaterial,
createFormexMaterial,
updateFormexMaterial,
deleteFormexMaterial,
getFormexCollections,
getFormexCategories
} from './routes/sync.js';
import {
uploadMiddleware,
analyzeRoom,
saveRoomConfig,
getRoomConfig,
checkApiStatus
} from './routes/room.js';
import {
optimizeLayoutRoute,
quickOptimizeRoute,
suggestLayoutRoute
} from './routes/layout.js';
import {
getModules,
getModule,
getModuleCategories,
getHardware,
getHardwareTypes,
getHandles,
getHinges
} from './routes/modules.js';
import {
generatePlansRoute,
generateCuttingListRoute,
exportCuttingListText,
exportPDFHtml
} from './routes/plans.js';
import {
designTipsRoute,
recommendMaterialsRoute,
renderRoute
} from './routes/ai.js';
import { initScheduler } from './services/scheduler.js';
const clientDist = path.join(__dirname, '..', 'client', 'dist');
const hasClientBuild = fs.existsSync(clientDist);
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors({ origin: true }));
app.use(express.json());
app.get('/api/products', getProducts);
app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes);
app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings);
app.post('/api/price', postPrice);
app.get('/api/orders', getOrders);
app.post('/api/orders', postOrder);
// Sync routes (Formex crawler)
app.post('/api/sync/formex', triggerFormexSync);
app.get('/api/sync/formex/stream', streamFormexSync);
app.get('/api/sync/logs', getLogs);
app.get('/api/sync/status', getStatus);
// Formex materials CRUD
app.get('/api/formex/materials', getFormexMaterials);
app.get('/api/formex/materials/:id', getFormexMaterial);
app.post('/api/formex/materials', createFormexMaterial);
app.put('/api/formex/materials/:id', updateFormexMaterial);
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
app.get('/api/formex/collections', getFormexCollections);
app.get('/api/formex/categories', getFormexCategories);
// Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
app.post('/api/room/save', saveRoomConfig);
app.get('/api/room/config/:orderId', getRoomConfig);
app.get('/api/room/status', checkApiStatus);
// Layout optimization routes (AI-powered)
app.post('/api/layout/optimize', optimizeLayoutRoute);
app.post('/api/layout/quick-optimize', quickOptimizeRoute);
app.get('/api/layout/suggest', suggestLayoutRoute);
// Modules catalog routes
app.get('/api/modules', getModules);
app.get('/api/modules/categories', getModuleCategories);
app.get('/api/modules/:id', getModule);
// Hardware routes
app.get('/api/hardware', getHardware);
app.get('/api/hardware/types', getHardwareTypes);
app.get('/api/handles', getHandles);
app.get('/api/hinges', getHinges);
// AI design assistant routes
app.post('/api/ai/design-tips', designTipsRoute);
app.post('/api/ai/recommend-materials', recommendMaterialsRoute);
app.post('/api/ai/render', renderRoute);
// Plans and cutting list routes
app.post('/api/plans/generate', generatePlansRoute);
app.post('/api/plans/cutting-list', generateCuttingListRoute);
app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml);
if (hasClientBuild) {
app.use(express.static(clientDist));
app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
} else {
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Configurateur</title></head>
<body style="font-family:sans-serif;padding:2rem;">
<h1>Application non construite</h1>
<p>Depuis la racine du projet, exécutez&nbsp;:</p>
<pre>npm run build</pre>
<p>Puis rechargez cette page. Ou lancez le client en mode dev&nbsp;:</p>
<pre>npm run client</pre>
<p>et ouvrez <a href="http://localhost:5173">http://localhost:5173</a>.</p>
</body></html>
`);
});
}
await dbReady;
// Initialize the scheduler for automated sync
initScheduler();
app.listen(PORT, () => {
console.log(`API configurateur armoires — http://localhost:${PORT}`);
if (!hasClientBuild) {
console.log('(Exécutez "npm run build" pour servir l\'app sur ce port)');
}
});
+1623
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "configurateur-armoires-server",
"version": "1.0.0",
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js",
"db:init": "node db/init.js",
"db:seed": "node db/seed.js",
"db:migrate": "node db/migrate.js"
},
"dependencies": {
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"openai": "^4.28.0",
"sql.js": "^1.11.0"
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* AI Design Assistant routes
* - POST /api/ai/design-tips → contextual design advice
* - POST /api/ai/recommend-materials → material/color recommendations
* - POST /api/ai/render → DALL-E 3 photorealistic render
*/
import { getDesignTip, recommendMaterials, generateRender } from '../services/designAdvisor.js';
/**
* POST /api/ai/design-tips
* Body: { config: { width, height, depth, columns, materialName, ... } }
*/
export async function designTipsRoute(req, res) {
try {
const { config } = req.body;
if (!config) {
return res.status(400).json({ error: 'Missing config in body' });
}
const result = await getDesignTip(config);
res.json(result);
} catch (err) {
console.error('[AI design-tips]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
res.status(500).json({ error: 'Failed to generate design tip' });
}
}
/**
* POST /api/ai/recommend-materials
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
*/
export async function recommendMaterialsRoute(req, res) {
try {
const { config, finishes, roomAnalysis } = req.body;
if (!config || !finishes) {
return res.status(400).json({ error: 'Missing config or finishes in body' });
}
const result = await recommendMaterials(config, finishes, roomAnalysis);
res.json(result);
} catch (err) {
console.error('[AI recommend-materials]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
res.status(500).json({ error: 'Failed to generate recommendations' });
}
}
/**
* POST /api/ai/render
* Body: { config, roomContext? }
*/
export async function renderRoute(req, res) {
try {
const { config, roomContext } = req.body;
if (!config) {
return res.status(400).json({ error: 'Missing config in body' });
}
const result = await generateRender(config, roomContext);
res.json(result);
} catch (err) {
console.error('[AI render]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
if (err.message.includes('billing') || err.message.includes('quota')) {
return res.status(402).json({ error: 'AI quota exceeded' });
}
res.status(500).json({ error: 'Failed to generate render' });
}
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getBlockTypes(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+67
View File
@@ -0,0 +1,67 @@
import pool from '../db/index.js';
/**
* GET /api/finishes
* Returns active Formex materials as finishes for the configurator
* Falls back to legacy finishes if no Formex materials exist
*/
export async function getFinishes(req, res) {
try {
// Check if we have active Formex materials
const formexResult = await pool.query(
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
);
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
if (hasFormexMaterials) {
// Return Formex materials as finishes
const { rows } = await pool.query(`
SELECT
id,
formex_code as code,
name as label,
name,
collection,
category,
width_mm,
height_mm,
thickness_mm,
image_url,
formex_url,
price_chf,
dominant_color as hexColor,
'formex' as source
FROM formex_materials
WHERE active = 1
ORDER BY collection, name
`);
// For each material, get its dimension variants
for (const material of rows) {
const dims = await pool.query(
`SELECT id, width_mm, height_mm, thickness_mm, is_default
FROM formex_dimensions WHERE material_id = ?
ORDER BY is_default DESC, width_mm`,
[material.id]
);
material.dimensions = dims.rows;
}
res.json(rows);
} else {
// Fallback to legacy finishes
const { rows } = await pool.query(`
SELECT f.id, f.code, f.label, f.panel_format_id,
pf.width_mm, pf.height_mm, pf.price_chf,
'legacy' as source
FROM finishes f
JOIN panel_formats pf ON pf.id = f.panel_format_id
WHERE f.active = 1
ORDER BY f.id
`);
res.json(rows);
}
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Layout optimization routes
*/
import { optimizeLayout, quickOptimize, suggestInitialLayout } from '../services/layoutOptimizer.js';
import pool from '../db/index.js';
/**
* POST /api/layout/optimize
* Optimize module layout using AI
*/
export async function optimizeLayoutRoute(req, res) {
try {
const { config, lockedModuleIds = [] } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
// Fetch available modules from catalog
const { rows: availableModules } = await pool.query(
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
);
if (availableModules.length === 0) {
return res.status(400).json({ error: 'Aucun module disponible dans le catalogue' });
}
const result = await optimizeLayout(config, availableModules, lockedModuleIds);
res.json(result);
} catch (error) {
console.error('Layout optimization error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
}
}
/**
* POST /api/layout/quick-optimize
* Quick local optimization without AI
*/
export async function quickOptimizeRoute(req, res) {
try {
const { config, lockedModuleIds = [] } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const result = quickOptimize(config, lockedModuleIds);
res.json(result);
} catch (error) {
console.error('Quick optimization error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
}
}
/**
* GET /api/layout/suggest?width=2400
* Suggest initial layout for given width
*/
export async function suggestLayoutRoute(req, res) {
try {
const totalWidth = parseInt(req.query.width) || 2400;
// Fetch available modules
const { rows: availableModules } = await pool.query(
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
);
if (availableModules.length === 0) {
return res.status(400).json({ error: 'Aucun module disponible' });
}
const suggestions = suggestInitialLayout(totalWidth, availableModules);
res.json({ success: true, modules: suggestions, totalWidth });
} catch (error) {
console.error('Layout suggestion error:', error);
res.status(500).json({ error: error.message || 'Erreur de suggestion' });
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Modules catalog and hardware routes
*/
import pool from '../db/index.js';
/**
* GET /api/modules
* Get all modules from catalog
*/
export async function getModules(req, res) {
try {
const { category, active } = req.query;
let sql = 'SELECT * FROM modules WHERE 1=1';
const params = [];
if (category) {
sql += ' AND category = ?';
params.push(category);
}
if (active !== undefined) {
sql += ' AND active = ?';
params.push(active === 'true' || active === '1' ? 1 : 0);
}
sql += ' ORDER BY category, width_mm, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get modules error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/modules/:id
* Get single module by ID
*/
export async function getModule(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM modules WHERE id = ?', [id]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Module non trouvé' });
}
res.json(rows[0]);
} catch (error) {
console.error('Get module error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/modules/categories
* Get distinct module categories
*/
export async function getModuleCategories(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT category FROM modules WHERE active = 1 ORDER BY category'
);
res.json(rows.map(r => r.category));
} catch (error) {
console.error('Get categories error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hardware
* Get all Blum hardware
*/
export async function getHardware(req, res) {
try {
const { type } = req.query;
let sql = 'SELECT * FROM blum_hardware WHERE active = 1';
const params = [];
if (type) {
sql += ' AND type = ?';
params.push(type);
}
sql += ' ORDER BY type, name';
const { rows } = await pool.query(sql, params);
// Parse specs_json for each item
const hardware = rows.map(h => ({
...h,
specs: h.specs_json ? JSON.parse(h.specs_json) : null
}));
res.json(hardware);
} catch (error) {
console.error('Get hardware error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hardware/types
* Get distinct hardware types
*/
export async function getHardwareTypes(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT type FROM blum_hardware WHERE active = 1 ORDER BY type'
);
res.json(rows.map(r => r.type));
} catch (error) {
console.error('Get hardware types error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/handles
* Get all handle variants
*/
export async function getHandles(req, res) {
try {
const { style } = req.query;
let sql = 'SELECT * FROM handle_variants WHERE active = 1';
const params = [];
if (style) {
sql += ' AND style = ?';
params.push(style);
}
sql += ' ORDER BY style, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get handles error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hinges
* Get all hinge variants
*/
export async function getHinges(req, res) {
try {
const { type } = req.query;
let sql = 'SELECT * FROM hinge_variants WHERE active = 1';
const params = [];
if (type) {
sql += ' AND type = ?';
params.push(type);
}
sql += ' ORDER BY type, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get hinges error:', error);
res.status(500).json({ error: error.message });
}
}
+27
View File
@@ -0,0 +1,27 @@
import pool, { run } from '../db/index.js';
export async function getOrders(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, config, total_chf, email, name, phone, notes, created_at FROM orders ORDER BY created_at DESC'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function postOrder(req, res) {
try {
const { config, totalChf, email, name, phone, notes } = req.body || {};
await run(
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
);
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Plans and cutting list routes
*/
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
/**
* POST /api/plans/generate
* Generate all 2D plans for a configuration
*/
export async function generatePlansRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const plans = generateAllPlans(config, modules, colors);
res.json({ success: true, plans });
} catch (error) {
console.error('Plan generation error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération des plans' });
}
}
/**
* POST /api/plans/cutting-list
* Generate cutting list for a configuration
*/
export async function generateCuttingListRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
res.json({ success: true, cuttingList });
} catch (error) {
console.error('Cutting list error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de débitage' });
}
}
/**
* POST /api/plans/export-text
* Export cutting list as text file
*/
export async function exportCuttingListText(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
const textContent = formatCuttingListForExport(cuttingList);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.txt"');
res.send(textContent);
} catch (error) {
console.error('Export text error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export' });
}
}
/**
* POST /api/plans/export-pdf
* Export plans and cutting list as HTML (for PDF conversion)
*/
export async function exportPDFHtml(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
const html = generatePDFHtml(config, modules, cuttingList);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
console.error('Export PDF error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
}
}
+79
View File
@@ -0,0 +1,79 @@
import pool from '../db/index.js';
import { calculatePrice } from '../services/priceEngine.js';
export async function postPrice(req, res) {
try {
const config = req.body || {};
// Fetch all required data
const [
products,
blockTypes,
legacyFinishes,
formexMaterials,
serviceOptions,
settingsRows,
modulesData,
handlesData,
hingesData,
blumData
] = await Promise.all([
pool.query('SELECT id, code, label FROM products WHERE active = true'),
pool.query('SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true'),
pool.query(`
SELECT f.id, f.code, f.label, pf.width_mm, pf.height_mm, pf.price_chf
FROM finishes f JOIN panel_formats pf ON pf.id = f.panel_format_id WHERE f.active = true
`),
pool.query(`
SELECT id, formex_code as code, name as label, name, collection, category,
width_mm, height_mm, thickness_mm, image_url, price_chf,
dominant_color as hexColor
FROM formex_materials WHERE active = 1
`),
pool.query('SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true'),
pool.query('SELECT key, value FROM settings'),
pool.query('SELECT id, code, name, category, width_mm, shelves_count, drawers_count, has_rod, base_price_chf FROM modules WHERE active = 1'),
pool.query('SELECT id, code, name, style, price_chf FROM handle_variants WHERE active = 1'),
pool.query('SELECT id, code, name, type, price_chf FROM hinge_variants WHERE active = 1'),
pool.query('SELECT id, code, name, type, variant, price_chf, specs_json FROM blum_hardware WHERE active = 1')
]);
const settings = settingsRows.rows.map((r) => ({ key: r.key, value: r.value }));
// Use Formex materials if available, otherwise fall back to legacy finishes
let finishes = legacyFinishes.rows;
if (formexMaterials.rows.length > 0) {
// Fetch dimension variants for Formex materials
const materialsWithDims = await Promise.all(
formexMaterials.rows.map(async (mat) => {
const dims = await pool.query(
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ?',
[mat.id]
);
return {
...mat,
dimensions: dims.rows
};
})
);
finishes = materialsWithDims;
}
const data = {
settings,
blockTypes: blockTypes.rows,
finishes,
serviceOptions: serviceOptions.rows,
modules: modulesData.rows,
handles: handlesData.rows,
hinges: hingesData.rows,
blumHardware: blumData.rows
};
const result = calculatePrice(config, data);
res.json(result);
} catch (err) {
console.error('Price calculation error:', err);
res.status(500).json({ error: err.message });
}
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getProducts(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, active FROM products WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Room analysis API routes
* Handles image upload and OpenAI Vision analysis
*/
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { analyzeRoomPhoto, analysisTo3DConfig } from '../services/roomAnalyzer.js';
import pool, { run } from '../db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Configure multer for image uploads
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB max
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Type de fichier non supporté. Utilisez JPEG, PNG ou WebP.'));
}
}
});
// Directory for storing uploaded room photos
const UPLOADS_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'rooms');
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
/**
* Middleware for single image upload
*/
export const uploadMiddleware = upload.single('image');
/**
* POST /api/room/analyze
* Upload a room photo and analyze it with GPT-4 Vision
*/
export async function analyzeRoom(req, res) {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune image fournie' });
}
// Convert buffer to base64
const imageBase64 = req.file.buffer.toString('base64');
const mimeType = req.file.mimetype;
// Analyze with OpenAI
console.log('Analyzing room photo with GPT-4 Vision...');
const result = await analyzeRoomPhoto(imageBase64, mimeType);
if (!result.success) {
return res.status(500).json({ error: 'Échec de l\'analyse' });
}
// Save the image for future reference
const filename = `room_${Date.now()}.${mimeType.split('/')[1]}`;
const filepath = path.join(UPLOADS_DIR, filename);
fs.writeFileSync(filepath, req.file.buffer);
// Convert to 3D config
const room3DConfig = analysisTo3DConfig(result.analysis);
res.json({
success: true,
imagePath: `/rooms/${filename}`,
analysis: result.analysis,
room3DConfig,
tokensUsed: result.tokensUsed
});
} catch (error) {
console.error('Room analysis error:', error);
res.status(500).json({
error: error.message || 'Erreur lors de l\'analyse de la pièce'
});
}
}
/**
* POST /api/room/save
* Save room configuration to an order
*/
export async function saveRoomConfig(req, res) {
try {
const { orderId, imagePath, analysis, room3DConfig } = req.body;
if (!analysis) {
return res.status(400).json({ error: 'Configuration de pièce requise' });
}
const analysisJson = JSON.stringify({ analysis, room3DConfig });
if (orderId) {
// Update existing order
await run(
`INSERT INTO room_configs (order_id, image_path, analysis_json)
VALUES (?, ?, ?)`,
[orderId, imagePath || null, analysisJson]
);
}
// Return the config ID for reference
const { rows } = await pool.query(
'SELECT last_insert_rowid() as id'
);
res.json({
success: true,
roomConfigId: rows[0]?.id || null
});
} catch (error) {
console.error('Save room config error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/room/config/:orderId
* Get room configuration for an order
*/
export async function getRoomConfig(req, res) {
try {
const { orderId } = req.params;
const { rows } = await pool.query(
'SELECT * FROM room_configs WHERE order_id = ? ORDER BY created_at DESC LIMIT 1',
[orderId]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Configuration non trouvée' });
}
const config = rows[0];
const parsed = JSON.parse(config.analysis_json || '{}');
res.json({
id: config.id,
orderId: config.order_id,
imagePath: config.image_path,
analysis: parsed.analysis,
room3DConfig: parsed.room3DConfig,
createdAt: config.created_at
});
} catch (error) {
console.error('Get room config error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* Check if OpenAI API is configured
*/
export function checkApiStatus(req, res) {
const isConfigured = !!process.env.OPENAI_API_KEY;
res.json({
openaiConfigured: isConfigured,
features: {
roomAnalysis: isConfigured
}
});
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getServiceOptions(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+16
View File
@@ -0,0 +1,16 @@
import pool from '../db/index.js';
export async function getSettings(req, res) {
try {
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
let v = r.value;
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
obj[r.key] = v;
}
res.json(obj);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+348
View File
@@ -0,0 +1,348 @@
/**
* Sync routes for Formex crawler
*/
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
/**
* POST /api/sync/formex
* Trigger a manual Formex sync (non-streaming)
*/
export async function triggerFormexSync(req, res) {
try {
// Run sync (this may take a while)
const result = await sync();
if (result.success) {
res.json({
success: true,
message: `Synchronisation terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
duration: result.duration
});
} else {
res.status(500).json({
success: false,
error: result.error,
itemsCount: result.itemsCount
});
}
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
}
/**
* GET /api/sync/formex/stream
* SSE endpoint for real-time sync progress
*/
export async function streamFormexSync(req, res) {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
res.flushHeaders();
// Emitter function that sends SSE events
const emit = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Handle client disconnect
req.on('close', () => {
// Client disconnected, cleanup if needed
});
try {
// Run sync with emitter
await sync(emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
// Close connection
res.end();
}
/**
* GET /api/sync/logs
* Get sync history
*/
export async function getLogs(req, res) {
try {
const limit = parseInt(req.query.limit || '20', 10);
const logs = await getSyncLogs(limit);
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/sync/status
* Get scheduler status
*/
export function getStatus(req, res) {
const status = getSchedulerStatus();
res.json(status);
}
/**
* GET /api/formex/materials
* Get Formex materials with filtering and pagination
*/
export async function getFormexMaterials(req, res) {
try {
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
// Build query with optional filters
let whereClauses = [];
let params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (collection) {
whereClauses.push('collection = ?');
params.push(collection);
}
if (category) {
whereClauses.push('category = ?');
params.push(category);
}
// For configurator: only active materials
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
// Get paginated results
const { rows } = await pool.query(
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
res.json({
materials: rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/materials/:id
* Get a single material by ID with dimensions and variants
*/
export async function getFormexMaterial(req, res) {
try {
const { id } = req.params;
// Get main material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
const material = rows[0];
// Get dimension variants
const dimensions = await pool.query(
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ? ORDER BY is_default DESC, width_mm',
[id]
);
material.dimensions = dimensions.rows;
// Get color/decor variants
const variants = await pool.query(
'SELECT id, variant_code, variant_name, variant_url, image_url FROM formex_variants WHERE material_id = ? ORDER BY variant_name',
[id]
);
material.variants = variants.rows;
res.json(material);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* POST /api/formex/materials
* Create a new material
*/
export async function createFormexMaterial(req, res) {
try {
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[formex_code]
);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
}
await run(
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
);
// Get the created material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE formex_code = ?',
[formex_code]
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* PUT /api/formex/materials/:id
* Update a material
*/
export async function updateFormexMaterial(req, res) {
try {
const { id } = req.params;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code (if changed)
if (formex_code !== existing.rows[0].formex_code) {
const duplicate = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
[formex_code, id]
);
if (duplicate.rows.length > 0) {
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
}
}
await run(
`UPDATE formex_materials
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, active = ?
WHERE id = ?`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
);
// Get the updated material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* DELETE /api/formex/materials/:id
* Delete a material
*/
export async function deleteFormexMaterial(req, res) {
try {
const { id } = req.params;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
await run('DELETE FROM formex_materials WHERE id = ?', [id]);
res.json({ success: true, message: 'Matériau supprimé' });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/collections
* Get distinct collection names for filter dropdown
*/
export async function getFormexCollections(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT collection FROM formex_materials ORDER BY collection'
);
res.json(rows.map(r => r.collection));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/categories
* Get distinct category names for filter dropdown
*/
export async function getFormexCategories(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT category FROM formex_materials WHERE category IS NOT NULL ORDER BY category'
);
res.json(rows.map(r => r.category));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
* Calculates all panel dimensions based on configuration and color assignments
*/
// Panel thickness in mm
const PANEL_THICKNESS = 19;
const BACK_PANEL_THICKNESS = 8;
// Edge banding settings
const EDGE_BANDING = {
visible: 2, // 2mm visible edge banding
hidden: 0.5 // 0.5mm thin edge banding
};
/**
* Element dimensions calculator
* Returns the dimensions for each element type based on wardrobe config
*/
function getElementDimensions(elementType, config, modules = []) {
const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = widthMm - (PANEL_THICKNESS * 2);
const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space
const isNiche = installationType === 'niche';
switch (elementType) {
case 'plintheBas':
return {
width: widthMm,
height: plinthHeightMm,
depth: depthMm,
quantity: 1,
description: 'Plinthe de base'
};
case 'bandeauHaut':
return {
width: widthMm,
height: PANEL_THICKNESS,
depth: depthMm,
quantity: 1,
description: 'Bandeau supérieur'
};
case 'dessus':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du dessus'
};
case 'fond':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du fond (bas)'
};
case 'cotes':
return {
width: PANEL_THICKNESS,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés extérieurs (gauche et droit)'
};
case 'cotesDoublage':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés de doublage (niche)'
};
case 'fileur':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Fileurs (bouche-trou niche)'
};
case 'dos':
return {
width: innerWidth,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: BACK_PANEL_THICKNESS,
quantity: 1,
description: 'Dos (panneau arrière)'
};
case 'portes':
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const isSliding = config.doorType === 'sliding';
if (isSliding) {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap
return {
width: panelWidth,
height: bodyHeight - 20, // clearance
depth: PANEL_THICKNESS,
quantity: panelCount,
description: `Portes coulissantes (${panelCount} vantaux)`
};
} else {
// Hinged doors - one per module/block
return {
width: Math.floor(widthMm / doorCount) - 4, // 2mm gap each side
height: bodyHeight - 4,
depth: PANEL_THICKNESS,
quantity: doorCount,
description: `Portes battantes (${doorCount} portes)`
};
}
case 'panneauxInterieurs':
// Vertical separators between modules
const separatorCount = modules.length > 1
? modules.length - 1
: (config.blocks?.length || 1) - 1;
if (separatorCount <= 0) return null;
return {
width: PANEL_THICKNESS,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: innerDepth,
quantity: separatorCount,
description: `Séparateurs verticaux (${separatorCount})`
};
case 'etageres':
// Count shelves from modules
const totalShelves = modules.reduce((sum, m) => sum + (m.shelves || 0), 0);
if (totalShelves <= 0) return null;
// Average shelf width based on modules
const avgModuleWidth = modules.length > 0
? modules.reduce((sum, m) => sum + (m.widthMm || 0), 0) / modules.length
: innerWidth;
return {
width: avgModuleWidth - (PANEL_THICKNESS * 2),
height: PANEL_THICKNESS,
depth: innerDepth - 20, // setback from front
quantity: totalShelves,
description: `Étagères (${totalShelves})`
};
case 'champs':
// Edge banding - calculated as linear meters
// Include all visible edges
const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4);
return {
width: EDGE_BANDING.visible,
height: perimeter,
depth: 0,
quantity: 1,
unit: 'ml',
description: 'Chant plaqué visible'
};
case 'caisse':
// The carcass/body - this is the assembled unit, not a cut piece
return null;
default:
return null;
}
}
/**
* Get edge banding requirements for an element
*/
function getEdgeBanding(elementType) {
const fullEdges = ['portes', 'cotes', 'dessus', 'plintheBas', 'bandeauHaut'];
const partialEdges = ['etageres', 'panneauxInterieurs', 'fond'];
if (fullEdges.includes(elementType)) {
return { type: 'visible', sides: 4 };
} else if (partialEdges.includes(elementType)) {
return { type: 'visible', sides: 1 }; // front edge only
} else if (elementType === 'dos') {
return { type: 'none', sides: 0 };
}
return { type: 'hidden', sides: 2 };
}
/**
* Generate complete cutting list for a wardrobe configuration
* @param {Object} config - Wardrobe configuration
* @param {Object} colors - Color/finish assignments per element
* @param {Array} modules - Module configurations (optional)
* @returns {Object} Cutting list with pieces grouped by finish
*/
export function generateCuttingList(config, colors = {}, modules = []) {
const pieces = [];
// All element types to process
const elementTypes = [
'plintheBas',
'bandeauHaut',
'dessus',
'fond',
'cotes',
'cotesDoublage',
'fileur',
'dos',
'portes',
'panneauxInterieurs',
'etageres'
];
for (const elementType of elementTypes) {
const dimensions = getElementDimensions(elementType, config, modules);
if (!dimensions) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
pieces.push({
id: `piece-${elementType}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: dimensions.depth <= PANEL_THICKNESS ? dimensions.depth : PANEL_THICKNESS,
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding: edgeBanding,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
});
}
// Calculate totals by finish
const byFinish = {};
for (const piece of pieces) {
const key = piece.finishLabel || 'Non défini';
if (!byFinish[key]) {
byFinish[key] = {
finishId: piece.finishId,
finishLabel: key,
pieces: [],
totalSurfaceM2: 0,
pieceCount: 0
};
}
byFinish[key].pieces.push(piece);
byFinish[key].totalSurfaceM2 += piece.surfaceM2;
byFinish[key].pieceCount += piece.quantity;
}
// Calculate edge banding totals
const edgeBandingMl = pieces.reduce((total, piece) => {
if (piece.edgeBanding.type !== 'none') {
const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters
return total + (perimeter * piece.edgeBanding.sides / 4);
}
return total;
}, 0);
return {
pieces,
byFinish: Object.values(byFinish),
summary: {
totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0),
totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0),
edgeBandingMl: Math.round(edgeBandingMl * 10) / 10,
finishCount: Object.keys(byFinish).length
},
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType
}
};
}
/**
* Calculate surface area in m²
*/
function calculateSurface(widthMm, heightMm, quantity = 1) {
return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000;
}
/**
* Format cutting list for display or export
*/
export function formatCuttingListForExport(cuttingList) {
const lines = [];
lines.push('LISTE DE DÉBITAGE');
lines.push('=================');
lines.push('');
lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`);
lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`);
lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`);
lines.push('');
lines.push('RÉSUMÉ');
lines.push('------');
lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`);
lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`);
lines.push(`Chant plaqué: ${cuttingList.summary.edgeBandingMl} ml`);
lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`);
lines.push('');
for (const group of cuttingList.byFinish) {
lines.push(`FINITION: ${group.finishLabel}`);
lines.push('-'.repeat(40));
lines.push('Pièce | Dim. (LxH) | Ép. | Qté | Surface');
for (const piece of group.pieces) {
lines.push(
`${piece.description.padEnd(30)} | ${piece.widthMm}x${piece.heightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m²`
);
}
lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`);
lines.push('');
}
return lines.join('\n');
}
export default { generateCuttingList, formatCuttingListForExport };
+213
View File
@@ -0,0 +1,213 @@
/**
* AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders
* Helps customers feel confident about their wardrobe design choices.
*/
import OpenAI from 'openai';
import crypto from 'crypto';
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return openaiClient;
}
/* -------------------------------------------------------------------------- */
/* 1. Design Tips (GPT-4o text) */
/* -------------------------------------------------------------------------- */
const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme.
À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum.
Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix.
Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances.
Réponds UNIQUEMENT en JSON :
{ "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`;
/**
* Generate a design tip for the current configuration
* @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType }
* @returns {{ tip: string, category: string }}
*/
export async function getDesignTip(config) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.9,
max_tokens: 200,
messages: [
{ role: 'system', content: DESIGN_TIP_PROMPT },
{ role: 'user', content: configSummary }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
return JSON.parse(raw);
} catch {
return { tip: raw, category: 'conseil' };
}
}
/* -------------------------------------------------------------------------- */
/* 2. Material Recommendations (GPT-4o) */
/* -------------------------------------------------------------------------- */
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
Critères de recommandation :
- Harmonie des couleurs avec les matériaux déjà sélectionnés
- Cohérence avec le style d'armoire (dimensions, configuration)
- Tendances actuelles en design d'intérieur suisse
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
Réponds UNIQUEMENT en JSON :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
]
}`;
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
// Compact finish list (only names + ids to save tokens)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
}
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
} catch {
return { recommendations: [] };
}
}
/* -------------------------------------------------------------------------- */
/* 3. Photorealistic Render (DALL-E 3) */
/* -------------------------------------------------------------------------- */
// Simple in-memory render cache (config hash → image URL)
const renderCache = new Map();
/**
* Generate a photorealistic render of the configured wardrobe using DALL-E 3
* @param {object} config - Current wardrobe configuration
* @param {object} [roomContext] - Optional room analysis for context
* @returns {{ imageUrl: string, cached: boolean }}
*/
export async function generateRender(config, roomContext) {
const client = getOpenAIClient();
// Cache by config hash
const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex');
if (renderCache.has(hash)) {
return { imageUrl: renderCache.get(hash), cached: true };
}
const prompt = buildRenderPrompt(config, roomContext);
const response = await client.images.generate({
model: 'dall-e-3',
prompt,
n: 1,
size: '1792x1024',
quality: 'hd',
style: 'natural'
});
const imageUrl = response.data[0]?.url;
if (imageUrl) {
renderCache.set(hash, imageUrl);
// Auto-evict after 30 minutes (DALL-E URLs expire after ~1h)
setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000);
}
return { imageUrl, cached: false };
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function buildConfigSummary(config) {
const columns = (config.columns || config.modules || [])
.map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`)
.join('\n');
return `Armoire sur mesure SIGNATURE
Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm
Installation: ${config.installationType || 'libre'}
Colonnes:\n${columns}
Matériau principal: ${config.materialName || config.finishLabel || 'non défini'}
Portes: ${config.doorType || 'battantes'}`;
}
function buildRenderPrompt(config, roomContext) {
const cols = (config.columns || config.modules || []);
const colDesc = cols.map((c, i) => {
const type = c.type || c.moduleCode || 'standard';
const parts = [];
if (c.hasRod) parts.push('a hanging rod');
if (c.shelves > 0) parts.push(`${c.shelves} shelves`);
if (c.drawers > 0) parts.push(`${c.drawers} drawers`);
return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`;
}).join('; ');
const widthM = ((config.width || 2400) / 1000).toFixed(1);
const heightM = ((config.height || 2400) / 1000).toFixed(1);
let roomDesc = 'a modern, well-lit bedroom with neutral walls';
if (roomContext) {
const walls = roomContext.walls || [];
const wallColors = walls.filter(w => w.color).map(w => w.color).join(', ');
roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`;
}
return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}.
The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}.
Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior.
Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level.
The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`;
}
+638
View File
@@ -0,0 +1,638 @@
/**
* Formex.ch web crawler service
* Scrapes panel/material data from the Formex catalog
*/
import * as cheerio from 'cheerio';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pool, { run } from '../db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE_URL = 'https://www.formex.ch';
const ASSORTIMENT_URL = `${BASE_URL}/fr/assortiment`;
const CRAWL_DELAY = parseInt(process.env.FORMEX_CRAWL_DELAY_MS || '500', 10);
// Directory for storing downloaded material images
const IMAGES_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'materials');
// Ensure images directory exists
if (!fs.existsSync(IMAGES_DIR)) {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
/**
* Sleep helper for rate limiting
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch a URL with error handling
*/
async function fetchPage(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-CH,fr;q=0.9'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
/**
* Download an image and save it locally
* @param {string} url - Image URL to download
* @param {string} filename - Local filename (e.g., "12345.jpg")
* @returns {string|null} - Local path or null if failed
*/
async function downloadImage(url, filename) {
try {
if (!url) return null;
// Ensure absolute URL
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
const response = await fetch(fullUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'image/*'
}
});
if (!response.ok) {
console.warn(`Failed to download image ${fullUrl}: ${response.status}`);
return null;
}
const contentType = response.headers.get('content-type') || '';
let ext = '.jpg';
if (contentType.includes('png')) ext = '.png';
else if (contentType.includes('webp')) ext = '.webp';
// Ensure filename has correct extension
const finalFilename = filename.replace(/\.[^.]+$/, '') + ext;
const filepath = path.join(IMAGES_DIR, finalFilename);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(filepath, buffer);
// Return the public path (relative to client/public)
return `/materials/${finalFilename}`;
} catch (err) {
console.warn(`Error downloading image ${url}:`, err.message);
return null;
}
}
/**
* Extract collection URLs from the assortiment page
*/
async function getCollections() {
const html = await fetchPage(ASSORTIMENT_URL);
const $ = cheerio.load(html);
const collections = [];
// Find collection links in the "Collections" section
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
const name = $(el).text().trim();
// Only include category pages (contain -c followed by numbers)
if (href && href.match(/-c\d+$/) && name) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
// Avoid duplicates
if (!collections.find(c => c.url === fullUrl)) {
collections.push({ name, url: fullUrl });
}
}
});
return collections;
}
/**
* Parse dimensions from a string like "2800mm x 2070mm x 19mm"
*/
function parseDimensions(dimStr) {
if (!dimStr) return { width: null, height: null, thickness: null };
const matches = dimStr.match(/(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm/i);
if (matches) {
return {
width: parseInt(matches[1], 10),
height: parseInt(matches[2], 10),
thickness: parseInt(matches[3], 10)
};
}
return { width: null, height: null, thickness: null };
}
/**
* Extract decor code from product name or page content
* e.g., "Panneaux mélaminés Duplex Plus 10000 Tilia" -> "10000"
*/
function extractDecorCode(text) {
// Look for a 4-5 digit code
const match = text.match(/\b(\d{4,5})\b/);
return match ? match[1] : null;
}
/**
* Map of common color keywords found in Formex product names to representative hex colors
*/
const COLOR_KEYWORDS = {
// Whites and off-whites
'blanc': '#f5f5f5', 'white': '#f5f5f5', 'weiss': '#f5f5f5', 'bianco': '#f5f5f5',
'crème': '#fffdd0', 'cream': '#fffdd0', 'ivoire': '#fffff0', 'ivory': '#fffff0',
'magnolia': '#f8f4f0', 'perle': '#eae0c8',
// Greys
'gris': '#909090', 'grey': '#909090', 'gray': '#909090', 'grau': '#909090',
'anthracite': '#383838', 'anthrazit': '#383838', 'graphite': '#4a4a4a',
'platine': '#e5e4e2', 'argent': '#c0c0c0', 'silver': '#c0c0c0',
'titane': '#878681',
// Blacks
'noir': '#1a1a1a', 'black': '#1a1a1a', 'schwarz': '#1a1a1a',
// Browns and woods
'chêne': '#b5895b', 'oak': '#b5895b', 'eiche': '#b5895b',
'noyer': '#6f4e37', 'walnut': '#6f4e37', 'nussbaum': '#6f4e37',
'hêtre': '#c9a46c', 'beech': '#c9a46c', 'buche': '#c9a46c',
'érable': '#d4b572', 'maple': '#d4b572', 'ahorn': '#d4b572',
'cerisier': '#9c3b17', 'cherry': '#9c3b17', 'kirsch': '#9c3b17',
'teck': '#b08444', 'teak': '#b08444',
'pin': '#dcc8a0', 'pine': '#dcc8a0',
'orme': '#a07850', 'elm': '#a07850',
'bouleau': '#e0c99c', 'birch': '#e0c99c',
'frêne': '#c8b88a', 'ash': '#c8b88a',
'acacia': '#b89244',
'wenge': '#4a3728', 'wengé': '#4a3728',
'mélèze': '#c4a06c', 'larch': '#c4a06c',
'aulne': '#b07c4c', 'alder': '#b07c4c',
'brun': '#8b4513', 'brown': '#8b4513', 'braun': '#8b4513',
'marron': '#6d3e1c', 'chocolat': '#4a2c14',
'cognac': '#9f381d', 'caramel': '#a67040',
'noisette': '#ab7746',
// Beiges and sandstones
'beige': '#d9c8a9', 'sable': '#c2b280', 'sand': '#c2b280',
'sahara': '#c4a661', 'champagne': '#f7e7ce',
'vanille': '#f3e5ab', 'vanilla': '#f3e5ab',
'cappuccino': '#a0785a', 'moka': '#6f4e37',
// Reds
'rouge': '#cc3333', 'red': '#cc3333', 'rot': '#cc3333',
'bordeaux': '#722f37', 'burgundy': '#722f37',
'terracotta': '#c65d3e', 'rouille': '#b7410e',
// Blues
'bleu': '#4169e1', 'blue': '#4169e1', 'blau': '#4169e1',
'marine': '#000080', 'navy': '#000080',
'ciel': '#87ceeb', 'azur': '#007fff',
'cobalt': '#0047ab', 'indigo': '#4b0082',
// Greens
'vert': '#4caf50', 'green': '#4caf50', 'grün': '#4caf50',
'olive': '#808000', 'sauge': '#9caf88', 'sage': '#9caf88',
'emeraude': '#50c878', 'mousse': '#738276',
// Yellows and golds
'jaune': '#f0c040', 'yellow': '#f0c040',
'or': '#d4af37', 'gold': '#d4af37',
'miel': '#cd8032', 'honey': '#cd8032',
// Stone and concrete
'béton': '#a0a0a0', 'beton': '#a0a0a0', 'concrete': '#a0a0a0',
'pierre': '#b0a898', 'stone': '#b0a898', 'stein': '#b0a898',
'ardoise': '#5a5a5a', 'slate': '#5a5a5a',
'marbre': '#e0d8d0', 'marble': '#e0d8d0',
'granit': '#808080', 'granite': '#808080',
'travertin': '#d1c6b0',
// Others
'rouille': '#b7410e', 'rust': '#b7410e',
'cuivre': '#b87333', 'copper': '#b87333',
'linen': '#e0d5c0', 'lin': '#e0d5c0',
'taupe': '#8b7d6b',
'mauve': '#e0b0ff', 'lilas': '#c8a2c8',
'rose': '#ffc0cb', 'pink': '#ffc0cb', 'rosa': '#ffc0cb'
};
/**
* Extract dominant color from product name by matching color keywords
* @param {string} name - Product name
* @returns {string|null} - Hex color code or null
*/
function extractDominantColor(name) {
if (!name) return null;
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const nameLower = name.toLowerCase();
// Try exact keyword matching (longest match first for compound words)
const sortedKeywords = Object.entries(COLOR_KEYWORDS)
.sort((a, b) => b[0].length - a[0].length);
for (const [keyword, hex] of sortedKeywords) {
// Check the raw name (preserving accents) and the normalized name
if (nameLower.includes(keyword) || lower.includes(keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))) {
return hex;
}
}
return null;
}
/**
* Get product details from a product detail page
* Enhanced to capture category, images, dimension variants, and color variants
*/
async function getProductDetails(productUrl) {
try {
const html = await fetchPage(productUrl);
const $ = cheerio.load(html);
// Get product name from h1
const name = $('h1').first().text().trim();
// Get decor code - look for "N° du décor:" or extract from name
let decorCode = null;
$('*').each((_, el) => {
const text = $(el).text();
const match = text.match(/N°\s*du\s*décor\s*:\s*(\d+)/i);
if (match) {
decorCode = match[1];
return false; // break
}
});
if (!decorCode) {
decorCode = extractDecorCode(name);
}
// Get category from breadcrumb (second-to-last item usually)
let category = null;
const breadcrumbLinks = $('a[href*="/fr/shop/"]').filter((_, el) => {
const href = $(el).attr('href') || '';
return href.match(/-c\d+$/); // Collection links
});
if (breadcrumbLinks.length > 0) {
category = $(breadcrumbLinks.last()).text().trim();
}
// Get main product image
let imageUrl = null;
// Look for product image in various selectors
$('img').each((_, el) => {
const src = $(el).attr('src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
// Try data-src for lazy-loaded images
if (!imageUrl) {
$('img[data-src]').each((_, el) => {
const src = $(el).attr('data-src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
}
// Get ALL dimension variants (multiple formats like "3650mm x 600mm x 29mm")
const dimensionVariants = [];
const seenDimensions = new Set();
$('a, button, span, div').each((_, el) => {
const text = $(el).text().trim();
const match = text.match(/^(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm$/i);
if (match) {
const key = `${match[1]}-${match[2]}-${match[3]}`;
if (!seenDimensions.has(key)) {
seenDimensions.add(key);
dimensionVariants.push({
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
thickness: parseInt(match[3], 10)
});
}
}
});
// Default dimensions (first variant or from legacy parsing)
let dimensions = dimensionVariants[0] || { width: null, height: null, thickness: null };
if (!dimensions.width) {
$('a, span, div').each((_, el) => {
const text = $(el).text();
if (text.match(/\d+\s*mm\s*x\s*\d+\s*mm\s*x\s*\d+\s*mm/i)) {
dimensions = parseDimensions(text);
return false;
}
});
}
// Get color/decor variants (from "Choisir la couleur" section)
const colorVariants = [];
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href') || '';
// Look for product links that are variants (not the current product)
if (href.match(/-p\d+$/) && !href.includes(productUrl.split('/').pop())) {
const variantUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
const variantName = $(el).attr('title') || $(el).text().trim();
const variantCode = extractDecorCode(variantName) || extractDecorCode(href);
// Look for variant thumbnail image
let variantImage = null;
const img = $(el).find('img').first();
if (img.length) {
variantImage = img.attr('src') || img.attr('data-src');
if (variantImage && !variantImage.startsWith('http')) {
variantImage = `${BASE_URL}${variantImage}`;
}
}
if (variantCode && variantName && !colorVariants.find(v => v.code === variantCode)) {
colorVariants.push({
code: variantCode,
name: variantName,
url: variantUrl,
imageUrl: variantImage
});
}
}
});
// Download main product image locally
let localImageUrl = null;
if (imageUrl && decorCode) {
localImageUrl = await downloadImage(imageUrl, `${decorCode}.jpg`);
}
// Download variant thumbnails
for (const variant of colorVariants) {
if (variant.imageUrl && variant.code) {
const localPath = await downloadImage(variant.imageUrl, `${variant.code}_thumb.jpg`);
if (localPath) {
variant.imageUrl = localPath;
}
}
}
// Extract a representative dominant color from the product name
const dominantColor = extractDominantColor(name);
return {
name,
decorCode,
dimensions,
dimensionVariants,
colorVariants,
category,
imageUrl: localImageUrl || imageUrl, // Prefer local path, fallback to URL
dominantColor
};
} catch (err) {
console.error(`Error fetching product details from ${productUrl}:`, err.message);
return null;
}
}
/**
* Get products from a collection page
* @param {string} collectionUrl
* @param {string} collectionName
* @param {Function} emit - Optional emitter for progress events
*/
async function getProductsFromCollection(collectionUrl, collectionName, emit = () => {}) {
const products = [];
const html = await fetchPage(collectionUrl);
const $ = cheerio.load(html);
// Find product links
const productLinks = new Set();
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
// Product pages contain -p followed by numbers
if (href && href.match(/-p\d+$/)) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
productLinks.add(fullUrl);
}
});
const productCount = productLinks.size;
console.log(` Found ${productCount} products in ${collectionName}`);
emit({ type: 'log', message: ` ${productCount} produits trouvés dans ${collectionName}` });
// Fetch each product's details
let productIndex = 0;
for (const productUrl of productLinks) {
await sleep(CRAWL_DELAY);
const details = await getProductDetails(productUrl);
productIndex++;
if (details && details.decorCode) {
products.push({
formex_code: details.decorCode,
name: details.name,
collection: collectionName,
category: details.category || collectionName,
width_mm: details.dimensions.width,
height_mm: details.dimensions.height,
thickness_mm: details.dimensions.thickness,
image_url: details.imageUrl,
formex_url: productUrl,
dominant_color: details.dominantColor,
dimensionVariants: details.dimensionVariants || [],
colorVariants: details.colorVariants || []
});
const dimCount = details.dimensionVariants?.length || 0;
const varCount = details.colorVariants?.length || 0;
emit({ type: 'log', message: ` [${productIndex}/${productCount}] ${details.name} (${dimCount} dims, ${varCount} variants)` });
}
}
return products;
}
/**
* Upsert a material into the database with enhanced data
*/
async function upsertMaterial(material) {
// Try to update first
const { rows } = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
let materialId;
if (rows.length > 0) {
materialId = rows[0].id;
// Update existing
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, dominant_color = COALESCE(?, dominant_color), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null, material.formex_code]
);
} else {
// Insert new
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, dominant_color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[material.formex_code, material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null]
);
// Get the inserted ID
const inserted = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
materialId = inserted.rows[0]?.id;
}
// Update dimension variants if we have a materialId
if (materialId && material.dimensionVariants && material.dimensionVariants.length > 0) {
// Delete existing dimensions
await run('DELETE FROM formex_dimensions WHERE material_id = ?', [materialId]);
// Insert new dimensions
for (let i = 0; i < material.dimensionVariants.length; i++) {
const dim = material.dimensionVariants[i];
await run(
`INSERT INTO formex_dimensions (material_id, width_mm, height_mm, thickness_mm, is_default)
VALUES (?, ?, ?, ?, ?)`,
[materialId, dim.width, dim.height, dim.thickness, i === 0 ? 1 : 0]
);
}
}
// Update color variants if we have a materialId
if (materialId && material.colorVariants && material.colorVariants.length > 0) {
// Delete existing variants
await run('DELETE FROM formex_variants WHERE material_id = ?', [materialId]);
// Insert new variants
for (const variant of material.colorVariants) {
await run(
`INSERT INTO formex_variants (material_id, variant_code, variant_name, variant_url, image_url)
VALUES (?, ?, ?, ?, ?)`,
[materialId, variant.code, variant.name, variant.url || null, variant.imageUrl || null]
);
}
}
return materialId;
}
/**
* Log sync result to database
*/
async function logSync(source, status, itemsCount, error = null) {
await run(
`INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)`,
[source, status, itemsCount, error]
);
}
/**
* Main sync function - crawls Formex and updates database
* @param {Function} emit - Optional emitter callback for SSE progress events
* Called with { type: 'log'|'progress'|'complete'|'error', ... }
*/
export async function sync(emit = () => {}) {
console.log('Starting Formex sync...');
emit({ type: 'log', message: 'Démarrage de la synchronisation Formex...' });
const startTime = Date.now();
let totalProducts = 0;
try {
// Get all collections
console.log('Fetching collections...');
emit({ type: 'log', message: 'Récupération des collections...' });
const collections = await getCollections();
const totalCollections = collections.length;
console.log(`Found ${totalCollections} collections`);
emit({ type: 'log', message: `${totalCollections} collections trouvées` });
emit({ type: 'progress', current: 0, total: totalCollections, collection: '' });
// Process each collection
for (let i = 0; i < collections.length; i++) {
const collection = collections[i];
console.log(`Processing: ${collection.name}`);
emit({ type: 'log', message: `Traitement: ${collection.name}` });
emit({ type: 'progress', current: i, total: totalCollections, collection: collection.name });
await sleep(CRAWL_DELAY);
try {
const products = await getProductsFromCollection(collection.url, collection.name, emit);
// Upsert each product
for (const product of products) {
await upsertMaterial(product);
totalProducts++;
}
emit({ type: 'log', message: ` ${products.length} produits enregistrés pour ${collection.name}` });
} catch (err) {
console.error(` Error processing ${collection.name}:`, err.message);
emit({ type: 'log', message: ` Erreur: ${err.message}` });
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`Sync complete: ${totalProducts} products in ${elapsed}s`);
await logSync('formex', 'success', totalProducts);
const result = {
success: true,
itemsCount: totalProducts,
duration: elapsed
};
emit({ type: 'progress', current: totalCollections, total: totalCollections, collection: '' });
emit({ type: 'complete', result });
return result;
} catch (err) {
console.error('Sync failed:', err.message);
await logSync('formex', 'error', totalProducts, err.message);
const result = {
success: false,
error: err.message,
itemsCount: totalProducts
};
emit({ type: 'error', error: err.message });
return result;
}
}
/**
* Get sync logs from database
*/
export async function getSyncLogs(limit = 20) {
const { rows } = await pool.query(
'SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT ?',
[limit]
);
return rows;
}
/**
* Get all scraped materials
*/
export async function getMaterials() {
const { rows } = await pool.query(
'SELECT * FROM formex_materials ORDER BY collection, name'
);
return rows;
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Layout Optimizer using OpenAI GPT-4
* Proposes optimal module arrangement within available width,
* respecting locked modules and optimizing for functionality and aesthetics
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt template for layout optimization
*/
const LAYOUT_OPTIMIZATION_PROMPT = `Tu es un expert en aménagement d'armoires sur mesure avec 20 ans d'expérience.
Propose un layout optimal pour les modules dans l'espace disponible.
CONTRAINTES:
- Largeur totale disponible: {totalWidth}mm
- Hauteur armoire: {height}mm
- Profondeur: {depth}mm
- Type d'installation: {installationType}
MODULES DISPONIBLES DANS LE CATALOGUE:
{availableModules}
MODULES ACTUELLEMENT PLACÉS:
{currentModules}
MODULES FIGÉS (ne pas déplacer, garder leur position exacte):
{lockedModules}
RÈGLES D'OPTIMISATION:
1. La somme des largeurs des modules doit égaler exactement la largeur totale
2. Les modules figés (locked) doivent rester à leur position exacte
3. Favoriser l'équilibre visuel (penderies aux extrémités, tiroirs au centre)
4. Regrouper les éléments de même type si possible
5. Pour une penderie principale, prévoir au moins 600mm de largeur
6. Les tiroirs sont plus pratiques à hauteur accessible (partie basse)
7. Laisser au moins un module rayonnage pour le linge plié
Retourne un JSON valide avec cette structure:
{
"layout": [
{
"moduleCode": "<code du module du catalogue>",
"moduleId": "<id unique du module dans la config, ou null si nouveau>",
"position": <index de position 0, 1, 2...>,
"widthMm": <largeur en mm>,
"locked": <true si figé, false sinon>,
"customizations": {
"shelves": <nombre d'étagères si personnalisable>,
"drawers": <nombre de tiroirs si applicable>,
"hasRod": <true/false pour tringle penderie>
}
}
],
"totalWidth": <somme des largeurs en mm>,
"reasoning": "<Explication en français du choix d'agencement>",
"alternatives": [
{
"description": "<description courte>",
"layout": [<même structure que layout principal>]
}
],
"warnings": ["<avertissements éventuels>"]
}
IMPORTANT: Retourne UNIQUEMENT du JSON valide, sans texte supplémentaire.`;
/**
* Build the prompt with actual data
*/
function buildPrompt(config, availableModules, lockedModuleIds = []) {
const currentModules = config.modules || [];
const lockedModules = currentModules.filter(m => lockedModuleIds.includes(m.id) || m.locked);
const unlockedModules = currentModules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const modulesCatalog = availableModules.map(m =>
`- ${m.code}: ${m.name} (${m.category}, largeur: ${m.width_mm}mm, ${m.shelves_count} étagères, ${m.drawers_count} tiroirs, penderie: ${m.has_rod ? 'oui' : 'non'}, prix: ${m.base_price_chf} CHF)`
).join('\n');
const currentModulesStr = currentModules.map((m, i) =>
`Position ${i}: ${m.moduleCode || 'module'} - ${m.widthMm}mm${m.locked ? ' [FIGÉ]' : ''}`
).join('\n') || 'Aucun module placé';
const lockedModulesStr = lockedModules.map((m, i) =>
`Position ${currentModules.indexOf(m)}: ${m.moduleCode || 'module'} - ${m.widthMm}mm`
).join('\n') || 'Aucun module figé';
return LAYOUT_OPTIMIZATION_PROMPT
.replace('{totalWidth}', config.widthMm || 2400)
.replace('{height}', config.heightMm || 2400)
.replace('{depth}', config.depthMm || 600)
.replace('{installationType}', config.installationType || 'free')
.replace('{availableModules}', modulesCatalog)
.replace('{currentModules}', currentModulesStr)
.replace('{lockedModules}', lockedModulesStr);
}
/**
* Optimize layout using GPT-4
* @param {Object} config - Current wardrobe configuration
* @param {Array} availableModules - List of available modules from catalog
* @param {Array} lockedModuleIds - IDs of modules that should not be moved
* @returns {Object} Optimized layout proposal
*/
export async function optimizeLayout(config, availableModules, lockedModuleIds = []) {
const openai = getOpenAIClient();
const prompt = buildPrompt(config, availableModules, lockedModuleIds);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const result = JSON.parse(content);
// Validate response structure
if (!result.layout || !Array.isArray(result.layout)) {
throw new Error('Invalid layout response structure');
}
// Validate total width matches
const totalLayoutWidth = result.layout.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalLayoutWidth - (config.widthMm || 2400)) > 10) {
result.warnings = result.warnings || [];
result.warnings.push(`La largeur totale (${totalLayoutWidth}mm) ne correspond pas exactement à la largeur demandée (${config.widthMm}mm)`);
}
return {
success: true,
...result,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Layout optimization error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Quick local optimization without AI (for simple cases)
* Distributes remaining space among unlocked modules
* @param {Object} config - Current configuration
* @param {Array} lockedModuleIds - IDs of locked modules
* @returns {Object} Optimized layout
*/
export function quickOptimize(config, lockedModuleIds = []) {
const modules = [...(config.modules || [])];
const totalWidth = config.widthMm || 2400;
// Calculate locked width
const lockedWidth = modules
.filter(m => lockedModuleIds.includes(m.id) || m.locked)
.reduce((sum, m) => sum + (m.widthMm || 0), 0);
// Get unlocked modules
const unlockedModules = modules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const remainingWidth = totalWidth - lockedWidth;
if (unlockedModules.length === 0) {
return { success: true, layout: modules, reasoning: 'Tous les modules sont figés' };
}
// Distribute remaining width equally among unlocked modules
const widthPerModule = Math.floor(remainingWidth / unlockedModules.length);
const remainder = remainingWidth - (widthPerModule * unlockedModules.length);
let extraAdded = 0;
const optimizedModules = modules.map(m => {
if (lockedModuleIds.includes(m.id) || m.locked) {
return m;
}
const extra = extraAdded < remainder ? 1 : 0;
extraAdded += extra;
return {
...m,
widthMm: widthPerModule + extra
};
});
return {
success: true,
layout: optimizedModules,
totalWidth: totalWidth,
reasoning: 'Répartition équitable de l\'espace entre les modules non-figés'
};
}
/**
* Suggest initial layout for empty configuration
* @param {number} totalWidth - Total available width in mm
* @param {Array} availableModules - Available module catalog
* @returns {Array} Suggested initial modules
*/
export function suggestInitialLayout(totalWidth, availableModules) {
const suggestions = [];
let remainingWidth = totalWidth;
// Standard layout: penderie - rayonnage - penderie for typical wardrobe
const penderie = availableModules.find(m => m.category === 'penderie' && m.width_mm <= remainingWidth / 3);
const rayonnage = availableModules.find(m => m.category === 'rayonnage');
if (penderie && rayonnage) {
// Two penderies on sides
const penderieWidth = Math.min(penderie.width_mm, Math.floor(totalWidth * 0.3));
const rayonnageWidth = totalWidth - (penderieWidth * 2);
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 0,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
suggestions.push({
moduleCode: rayonnage.code,
moduleTypeId: rayonnage.id,
widthMm: rayonnageWidth,
position: 1,
locked: false,
shelves: rayonnage.shelves_count,
drawers: 0,
hasRod: false
});
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 2,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
}
return suggestions;
}
export default { optimizeLayout, quickOptimize, suggestInitialLayout };
+358
View File
@@ -0,0 +1,358 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
*/
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
}
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate a dimension line with arrows and text
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
// Label (rotated)
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
/**
* Generate all plans for a configuration
*/
export function generateAllPlans(config, modules = [], colors = {}) {
return {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
metadata: {
generatedAt: new Date().toISOString(),
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
}
}
};
}
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
.page { page-break-after: always; padding: 20px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.title { font-size: 20px; font-weight: bold; margin: 0; }
.subtitle { font-size: 12px; color: #666; margin: 5px 0 0; }
.plan-container { margin: 20px 0; text-align: center; }
.plan-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
.summary { background: #f0f7ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
<h3>Spécifications</h3>
<div class="specs">
<div class="spec-item"><span>Largeur:</span><strong>${config.widthMm} mm</strong></div>
<div class="spec-item"><span>Hauteur:</span><strong>${config.heightMm} mm</strong></div>
<div class="spec-item"><span>Profondeur:</span><strong>${config.depthMm} mm</strong></div>
<div class="spec-item"><span>Plinthe:</span><strong>${config.plinthHeightMm} mm</strong></div>
<div class="spec-item"><span>Type:</span><strong>${config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</strong></div>
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Vue de côté</div>
${plans.side}
</div>
<div class="plan-container">
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
<div class="page">
<h2>Liste de débitage</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
for (const group of cuttingList.byFinish) {
html += `
<h3>Finition: ${group.finishLabel}</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
html += `
<tr>
<td>${piece.description}</td>
<td>${piece.widthMm} mm</td>
<td>${piece.heightMm} mm</td>
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateAllPlans,
generatePDFHtml
};
+422
View File
@@ -0,0 +1,422 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
*/
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
// Portes (surface face visible)
if (doorType === 'sliding' && slidingPanelsCount) {
const panelWidth = widthMm / slidingPanelsCount;
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
} else {
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
const doorWidth = widthMm / Math.max(1, doorsCount);
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
}
// Plinthe (face avant)
surface += widthMm * plinthHeightMm;
// Bandeau et fileurs si niche
if (isNiche) {
surface += widthMm * plinthHeightMm; // bandeau haut
surface += 2 * plinthHeightMm * (heightMm - plinthHeightMm); // fileurs gauche/droite
}
// Côtés de doublage (coulissant)
if (doorType === 'sliding') {
surface += 2 * (depthMm * (heightMm - plinthHeightMm));
}
return surface / 1_000_000; // m²
}
/**
* Nombre de panneaux entiers nécessaires (arrondi supérieur).
* Coût = nombre_panneaux × prix_panneau.
*/
function panelCost(surfaceM2, panelWidthMm, panelHeightMm, pricePerPanelChf) {
const panelSurfaceM2 = (panelWidthMm * panelHeightMm) / 1_000_000;
const count = Math.ceil(surfaceM2 / panelSurfaceM2);
return { panels: Math.max(0, count), costChf: count * pricePerPanelChf };
}
/**
* Find the optimal dimension variant that minimizes cost (fewest panels needed).
* @param {number} surfaceM2 - Required surface in square meters
* @param {Array} dimensions - Array of dimension variants [{width_mm, height_mm, thickness_mm, id}]
* @param {number} pricePerPanel - Price per panel in CHF
* @returns {Object} - Optimal dimension with cost details and alternatives
*/
function findOptimalDimension(surfaceM2, dimensions, pricePerPanel) {
if (!dimensions || dimensions.length === 0) {
return null;
}
const price = pricePerPanel || 0;
const results = [];
for (const dim of dimensions) {
if (!dim.width_mm || !dim.height_mm) continue;
const panelSurfaceM2 = (dim.width_mm * dim.height_mm) / 1_000_000;
const panelsNeeded = Math.ceil(surfaceM2 / panelSurfaceM2);
const totalCost = panelsNeeded * price;
const totalSurface = panelsNeeded * panelSurfaceM2;
const wasteM2 = totalSurface - surfaceM2;
const wastePercent = (wasteM2 / totalSurface) * 100;
results.push({
dimension: {
id: dim.id,
width_mm: dim.width_mm,
height_mm: dim.height_mm,
thickness_mm: dim.thickness_mm
},
panelsNeeded,
costChf: totalCost,
panelSurfaceM2,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round(wastePercent * 10) / 10
});
}
// Sort by cost (lowest first), then by waste (lowest first)
results.sort((a, b) => {
if (a.costChf !== b.costChf) return a.costChf - b.costChf;
return a.wasteM2 - b.wasteM2;
});
if (results.length === 0) return null;
const optimal = results[0];
const alternatives = results.slice(1);
return {
selectedDimension: optimal.dimension,
panelsNeeded: optimal.panelsNeeded,
costChf: optimal.costChf,
wasteM2: optimal.wasteM2,
wastePercent: optimal.wastePercent,
alternatives: alternatives.map(alt => ({
dimension: alt.dimension,
panelsNeeded: alt.panelsNeeded,
costChf: alt.costChf,
wasteM2: alt.wasteM2,
wastePercent: alt.wastePercent
}))
};
}
/**
* Calculate module costs (V2 mode)
* @param {Array} modules - Array of module configurations
* @param {Array} moduleCatalog - Available modules from database
* @returns {Object} - Module costs breakdown
*/
function calculateModuleCosts(modules, moduleCatalog) {
let totalChf = 0;
const breakdown = [];
for (const module of modules) {
const catalogModule = moduleCatalog.find(m => m.id === module.moduleTypeId || m.code === module.moduleCode);
const basePrice = catalogModule?.base_price_chf || 450;
// Adjust price based on customizations
let adjustedPrice = basePrice;
// Extra shelves cost (beyond default)
const defaultShelves = catalogModule?.shelves_count || 0;
const extraShelves = Math.max(0, (module.shelves || 0) - defaultShelves);
adjustedPrice += extraShelves * 25; // 25 CHF per extra shelf
// Extra drawers cost (beyond default)
const defaultDrawers = catalogModule?.drawers_count || 0;
const extraDrawers = Math.max(0, (module.drawers || 0) - defaultDrawers);
adjustedPrice += extraDrawers * 45; // 45 CHF per extra drawer
// Width adjustment (if different from default)
const defaultWidth = catalogModule?.width_mm || 600;
const widthDiff = (module.widthMm || defaultWidth) - defaultWidth;
if (widthDiff > 0) {
adjustedPrice += widthDiff * 0.15; // 0.15 CHF per extra mm
}
breakdown.push({
moduleCode: module.moduleCode || catalogModule?.code,
moduleName: catalogModule?.name || 'Module',
basePrice,
adjustedPrice: Math.round(adjustedPrice * 100) / 100,
adjustments: {
extraShelves,
extraDrawers,
widthDiff
}
});
totalChf += adjustedPrice;
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Calculate hardware costs (V2)
* @param {Object} hardware - Hardware selection {handleVariantId, hingeVariantId, drawerSystemId}
* @param {Object} hardwareData - Hardware data from DB {handles, hinges, blum}
* @param {Object} config - Config with door and drawer counts
* @returns {Object} - Hardware costs breakdown
*/
function calculateHardwareCosts(hardware, hardwareData, config) {
const { handles = [], hinges = [], blum = [] } = hardwareData;
const breakdown = [];
let totalChf = 0;
// Handle costs - one per door
if (hardware.handleVariantId) {
const handle = handles.find(h => h.id === hardware.handleVariantId);
if (handle) {
const doorCount = config.doorType === 'sliding'
? (config.slidingPanelsCount || 2)
: (config.modules?.length || config.blocks?.length || 2);
const cost = handle.price_chf * doorCount;
totalChf += cost;
breakdown.push({
type: 'handle',
name: handle.name,
unitPrice: handle.price_chf,
quantity: doorCount,
totalChf: cost
});
}
}
// Hinge costs - typically 3 per hinged door
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2;
const hingesPerDoor = 3;
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
breakdown.push({
type: 'hinge',
name: hinge.name,
unitPrice: hinge.price_chf,
quantity,
totalChf: cost
});
}
}
// Drawer system costs - one per drawer
if (hardware.drawerSystemId) {
const system = blum.find(h => h.id === hardware.drawerSystemId && h.type === 'drawer_system');
if (system) {
const modules = config.modules || [];
const drawerCount = modules.reduce((sum, m) => sum + (m.drawers || 0), 0);
if (drawerCount > 0) {
const cost = system.price_chf * drawerCount;
totalChf += cost;
breakdown.push({
type: 'drawer_system',
name: system.name,
unitPrice: system.price_chf,
quantity: drawerCount,
totalChf: cost
});
}
}
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Valide la configuration et calcule le prix total.
* @param {Object} config - configuration client
* @param {Object} data - données DB (settings, blockTypes, finishes, serviceOptions, modules, hardware)
*/
export function calculatePrice(config, data) {
const errors = [];
const settingsArr = data.settings || [];
const settings = {};
for (const r of settingsArr) {
let v = r.value;
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
settings[r.key] = v;
}
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
} = config;
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
// Plinthe 60–160 mm
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
}
// Portes coulissantes : largeur totale >= 1200 mm
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
if (useModules) {
// V2: Use module-based pricing
const moduleCatalog = data.modules || [];
const moduleCosts = calculateModuleCosts(modules, moduleCatalog);
blocksChf = moduleCosts.totalChf;
modulesBreakdown = moduleCosts.breakdown;
// Validate module widths
const totalModuleWidth = modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalModuleWidth - widthMm) > 10) {
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
if (Math.abs(totalWidth - widthMm) > 5 && blocks.length > 0) {
errors.push({ field: 'blocks', message: 'La somme des largeurs des blocs doit correspondre à la largeur totale.' });
}
const blockTypes = data.blockTypes || [];
for (const block of blocks) {
const bt = blockTypes.find((b) => b.id === block.blockTypeId || b.code === block.blockTypeCode);
if (bt) blocksChf += Number(bt.base_price_chf) || 550;
else blocksChf += 550;
}
}
// Calculate hardware costs (V2)
let hardwareChf = 0;
let hardwareBreakdown = null;
if (hardware && (hardware.handleVariantId || hardware.hingeVariantId || hardware.drawerSystemId)) {
const hardwareData = {
handles: data.handles || [],
hinges: data.hinges || [],
blum: data.blumHardware || []
};
const hardwareCosts = calculateHardwareCosts(hardware, hardwareData, config);
hardwareChf = hardwareCosts.totalChf;
hardwareBreakdown = hardwareCosts.breakdown;
}
let panelsChf = 0;
let panelOptimization = null;
const surfaceM2 = calculateVisibleSurface({
...config,
widthMm: widthMm || blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0),
plinthHeightMm: plinthHeightMm || 100,
blocks: useModules ? modules.map(m => ({ widthMm: m.widthMm })) : blocks,
doorType: doorType || 'hinged',
slidingPanelsCount: slidingPanelsCount || 2
});
// Find finish/material by ID or code
const finish = (data.finishes || []).find((f) => f.id === finishId || f.code === config.finishCode);
if (finish) {
const priceChf = finish.price_chf;
// Check if we have multiple dimension variants for optimization
if (finish.dimensions && finish.dimensions.length > 0 && priceChf != null) {
// Auto-optimize: find the best dimension that minimizes cost
panelOptimization = findOptimalDimension(surfaceM2, finish.dimensions, Number(priceChf));
if (panelOptimization) {
panelsChf = panelOptimization.costChf;
}
} else if (finish.width_mm && finish.height_mm && priceChf != null) {
// Legacy: single dimension from finish
const result = panelCost(surfaceM2, finish.width_mm, finish.height_mm, Number(priceChf));
panelsChf = result.costChf;
// Create optimization info for legacy finishes
const panelSurfaceM2 = (finish.width_mm * finish.height_mm) / 1_000_000;
const wasteM2 = (result.panels * panelSurfaceM2) - surfaceM2;
panelOptimization = {
selectedDimension: {
width_mm: finish.width_mm,
height_mm: finish.height_mm,
thickness_mm: null
},
panelsNeeded: result.panels,
costChf: result.costChf,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round((wasteM2 / (result.panels * panelSurfaceM2)) * 1000) / 10,
alternatives: []
};
}
}
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
const opt = services.find((s) => s.id === optId || s.code === optId);
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
surfaceM2: Math.round(surfaceM2 * 1000) / 1000,
details: {
blocksChf,
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
errors: errors.length ? errors : undefined
};
}
export default {
calculatePrice,
calculateVisibleSurface,
panelCost,
findOptimalDimension,
calculateModuleCosts,
calculateHardwareCosts
};
+245
View File
@@ -0,0 +1,245 @@
/**
* Room Photo Analyzer using OpenAI GPT-4 Vision
* Analyzes room photos to extract dimensions, detect doors/windows, and suggest wardrobe placement
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt for GPT-4 Vision to analyze room photos
*/
const ROOM_ANALYSIS_PROMPT = `You are a professional interior designer and architect specializing in custom wardrobe installations. Analyze this room photo with a STRONG FOCUS on the visible wall surfaces (parois) and ceiling (plafond).
PRIORITY: Carefully examine every visible wall and the ceiling. Detect their exact colors, materials, and textures as they appear in the photo. This is critical for accurate 3D room reconstruction.
Return a JSON object with the following structure:
{
"roomDimensions": {
"widthM": <estimated width in meters, e.g., 4.2>,
"lengthM": <estimated length/depth in meters, e.g., 3.8>,
"heightM": <estimated ceiling height in meters, typically 2.4-2.7>
},
"walls": [
{
"side": "back" | "left" | "right",
"visible": <boolean - is this wall clearly visible in the photo?>,
"color": "<EXACT hex color code sampled from the wall surface, e.g., #f5f5f0>",
"material": "paint" | "wallpaper" | "brick" | "wood_panel" | "concrete" | "plaster" | "stone" | "tile",
"texture": "smooth" | "textured" | "patterned" | "rough" | "glossy",
"hasWindow": <boolean>,
"hasDoor": <boolean>,
"surfaceArea": <approximate visible surface area percentage 0-100, accounting for windows/doors>
}
],
"windows": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"yPercent": <vertical position from floor, 0-100>,
"widthPercent": <width as percentage of wall, e.g., 25>,
"heightPercent": <height as percentage of wall, e.g., 40>,
"type": "window" | "french_window" | "skylight" | "bay_window"
}
],
"doors": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"widthPercent": <width as percentage of wall, typically 15-25>,
"heightPercent": <height as percentage of wall, typically 80-90>,
"type": "door" | "double_door" | "opening" | "closet" | "sliding_door"
}
],
"floor": {
"color": "<exact hex color code>",
"material": "wood" | "tile" | "carpet" | "concrete" | "laminate" | "vinyl" | "parquet"
},
"ceiling": {
"visible": <boolean - is the ceiling visible in the photo?>,
"color": "<EXACT hex color code detected from the ceiling surface>",
"type": "flat" | "sloped" | "beamed" | "coffered" | "vaulted",
"material": "paint" | "plaster" | "wood" | "concrete",
"slopeAngleDeg": <angle in degrees if sloped, 0 for flat, null if not applicable>,
"slopeDirection": "front_to_back" | "left_to_right" | null
},
"lighting": {
"natural": "bright" | "moderate" | "dim",
"windowSide": "left" | "right" | "back" | "front" | null
},
"suggestedWardrobeWall": "back" | "left" | "right",
"suggestedWardrobePosition": {
"xPercent": <start position from left, 0-100>,
"widthPercent": <suggested width as percentage of wall, e.g., 50>,
"reason": "<brief explanation of why this position is recommended>"
},
"roomType": "bedroom" | "living_room" | "hallway" | "dressing" | "other",
"confidence": <confidence score 0-100>
}
IMPORTANT GUIDELINES FOR WALL AND CEILING DETECTION:
- ALWAYS include all three walls (back, left, right). Set visible=false only if a wall is truly not visible at all.
- Sample the EXACT hex color from the dominant area of each wall surface — do NOT default to #f5f5f0.
- Pay close attention to paint color nuances: beige vs cream vs off-white vs warm grey etc.
- Detect the wall material: painted plaster, wallpaper, exposed brick, wood paneling, etc.
- For the ceiling: detect its actual color (it may not be pure white), its type, and material.
- If the ceiling is sloped (mansarde/sous-pente), estimate the slope angle and direction.
- Estimate dimensions based on standard furniture sizes if visible (beds, doors, windows).
- Standard door height is ~2.1m, standard door width is ~0.8-0.9m.
- Standard ceiling height is 2.4-2.7m in residential.
- For wardrobe placement, prefer walls without windows/doors and with enough clear space.
- Return ONLY valid JSON, no additional text.`;
/**
* Analyze a room photo using GPT-4 Vision
* @param {string} imageBase64 - Base64 encoded image data
* @param {string} mimeType - Image MIME type (image/jpeg, image/png, etc.)
* @returns {Object} Room analysis result
*/
export async function analyzeRoomPhoto(imageBase64, mimeType = 'image/jpeg') {
const openai = getOpenAIClient();
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: ROOM_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
detail: 'high'
}
}
]
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const analysis = JSON.parse(content);
// Validate required fields
if (!analysis.roomDimensions || !analysis.suggestedWardrobeWall) {
throw new Error('Incomplete room analysis');
}
return {
success: true,
analysis,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Room analysis error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Convert analysis to 3D room configuration for Three.js
* @param {Object} analysis - Room analysis from GPT-4 Vision
* @returns {Object} 3D room configuration
*/
export function analysisTo3DConfig(analysis) {
const { roomDimensions, walls, windows, doors, floor, ceiling, suggestedWardrobeWall, suggestedWardrobePosition } = analysis;
return {
// Room dimensions in meters
width: roomDimensions.widthM || 4,
length: roomDimensions.lengthM || 4,
height: roomDimensions.heightM || 2.5,
// Wall configurations with material info
walls: {
back: (() => {
const w = walls?.find(w => w.side === 'back');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
left: (() => {
const w = walls?.find(w => w.side === 'left');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
right: (() => {
const w = walls?.find(w => w.side === 'right');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})()
},
// Windows for each wall
windows: (windows || []).map(w => ({
wall: w.wall,
x: (w.xPercent || 50) / 100,
y: (w.yPercent || 30) / 100,
width: (w.widthPercent || 25) / 100,
height: (w.heightPercent || 40) / 100,
type: w.type || 'window'
})),
// Doors for each wall
doors: (doors || []).map(d => ({
wall: d.wall,
x: (d.xPercent || 10) / 100,
width: (d.widthPercent || 20) / 100,
height: (d.heightPercent || 85) / 100,
type: d.type || 'door'
})),
// Floor and ceiling
floor: {
color: floor?.color || '#c4a574',
material: floor?.material || 'wood'
},
ceiling: {
color: ceiling?.color || '#ffffff',
type: ceiling?.type || 'flat',
visible: ceiling?.visible !== false,
material: ceiling?.material || 'paint',
slopeAngleDeg: ceiling?.slopeAngleDeg || 0,
slopeDirection: ceiling?.slopeDirection || null
},
// Wardrobe placement
wardrobePlacement: {
wall: suggestedWardrobeWall || 'back',
xPercent: suggestedWardrobePosition?.xPercent || 25,
widthPercent: suggestedWardrobePosition?.widthPercent || 50
}
};
}
export default { analyzeRoomPhoto, analysisTo3DConfig };
+60
View File
@@ -0,0 +1,60 @@
/**
* Scheduler service using node-cron
* Handles scheduled tasks like Formex sync
*/
import cron from 'node-cron';
import { sync as formexSync } from './formexCrawler.js';
let formexJob = null;
/**
* Initialize the scheduler with configured jobs
*/
export function initScheduler() {
const cronExpression = process.env.FORMEX_SYNC_CRON || '0 3 * * *'; // Default: 3:00 AM daily
// Validate cron expression
if (!cron.validate(cronExpression)) {
console.error(`Invalid FORMEX_SYNC_CRON expression: ${cronExpression}`);
return;
}
// Schedule Formex sync
formexJob = cron.schedule(cronExpression, async () => {
console.log(`[${new Date().toISOString()}] Running scheduled Formex sync...`);
try {
const result = await formexSync();
console.log(`[${new Date().toISOString()}] Scheduled sync complete:`, result);
} catch (err) {
console.error(`[${new Date().toISOString()}] Scheduled sync failed:`, err.message);
}
}, {
scheduled: true,
timezone: 'Europe/Zurich' // Swiss timezone
});
console.log(`Formex sync scheduled: ${cronExpression} (Europe/Zurich)`);
}
/**
* Stop all scheduled jobs
*/
export function stopScheduler() {
if (formexJob) {
formexJob.stop();
formexJob = null;
console.log('Scheduler stopped');
}
}
/**
* Get scheduler status
*/
export function getSchedulerStatus() {
return {
formexSync: {
running: formexJob !== null,
schedule: process.env.FORMEX_SYNC_CRON || '0 3 * * *'
}
};
}