Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+110
View File
@@ -0,0 +1,110 @@
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation } from '../../shared/manufacturing.js';
function toSafeNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toLabel(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9_\-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
}
function emitPanelOutline(lines, widthMm, heightMm, safeZ, cutZ, feed) {
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push(`G1 Z${cutZ.toFixed(3)} F${Math.round(feed)}`);
lines.push(`G1 X${widthMm.toFixed(3)} Y0.000`);
lines.push(`G1 X${widthMm.toFixed(3)} Y${heightMm.toFixed(3)}`);
lines.push(`G1 X0.000 Y${heightMm.toFixed(3)}`);
lines.push('G1 X0.000 Y0.000');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
}
function emitHole(lines, { xMm, yMm, diameterMm = 5, depthMm = 10, type = 'generic', face = 'face', end = null }) {
let tail = `TYPE=${String(type)} FACE=${face}`;
if (end) tail += ` END=${end}`;
lines.push(
`; HOLE X=${Number(xMm).toFixed(3)} Y=${Number(yMm).toFixed(3)} D=${Number(diameterMm).toFixed(3)} Z=${Number(depthMm).toFixed(3)} ${tail}`
);
}
function emitPieceHoleComments(lines, piece, widthMm, heightMm, partIndex = 0) {
const { specs, thicknessMm: _tm } = collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex);
for (const spec of specs) {
emitHole(lines, spec);
}
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (m.groove && orient) {
const gw = Number(m.groove.width ?? GROOVE.back.width);
const gd = Number(m.groove.depth ?? GROOVE.back.depth);
const ax = Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge);
lines.push(`; GROOVE BACK W=${gw.toFixed(3)} D=${gd.toFixed(3)} AXIS=${ax.toFixed(3)} ORIENT=${orient}`);
}
}
export function generateCncProgram(config, modules = [], colors = {}) {
const cuttingList = generateCuttingList(config, colors, modules);
const safeZ = 10;
const cutDepth = 3;
const feedCut = 1800;
const lines = [
'%',
'; APEX CNC ISO (G-code) - v1',
`; Generated: ${new Date().toISOString()}`,
`; Config: ${config.widthMm}x${config.heightMm}x${config.depthMm} mm`,
'G21 ; metric units',
'G90 ; absolute coordinates',
'G17 ; XY plane',
`G0 Z${safeZ.toFixed(3)}`
];
let programPartCount = 0;
for (const piece of cuttingList.pieces || []) {
const widthMm = toSafeNumber(piece.rawCutWidthMm, piece.widthMm);
const heightMm = toSafeNumber(piece.rawCutHeightMm, piece.heightMm);
const qty = Math.max(1, Math.round(toSafeNumber(piece.quantity, 1)));
const thickness = toSafeNumber(piece.thicknessMm, 19);
const pieceName = toLabel(piece.description || piece.elementType || 'PIECE');
const targetDepth = -Math.min(cutDepth, Math.max(1, thickness * 0.25));
for (let i = 0; i < qty; i++) {
programPartCount += 1;
lines.push('');
lines.push(`; --- PART ${programPartCount}: ${pieceName} #${i + 1} ---`);
lines.push(`; SIZE_RAW: X=${widthMm.toFixed(3)} Y=${heightMm.toFixed(3)} T=${thickness.toFixed(3)}`);
emitPieceHoleComments(lines, piece, widthMm, heightMm, i);
emitPanelOutline(lines, widthMm, heightMm, safeZ, targetDepth, feedCut);
}
}
lines.push('');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push('M30');
lines.push('%');
return {
fileName: 'apex-program.nc',
mimeType: 'text/plain; charset=utf-8',
content: lines.join('\n'),
metadata: {
generatedAt: new Date().toISOString(),
parts: programPartCount
}
};
}
export default {
generateCncProgram
};
+97
View File
@@ -0,0 +1,97 @@
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
export function validateConfig(config = {}, settings = {}) {
const errors = [];
const warnings = [];
const installationType = config.installationType || 'free';
const doorType = config.doorType || 'hinged';
const widthMm = Number(config.widthMm || 0);
const heightMm = Number(config.heightMm || 0);
const depthMm = Number(config.depthMm || 0);
const modules = Array.isArray(config.modules) ? config.modules : [];
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
if (doorType === 'sliding' && !allowSliding) {
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(expectedW - widthMm) > 2 || Math.abs(expectedD - depthMm) > 2 || Math.abs(expectedH - heightMm) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Dimensions niche incohérentes avec les mesures mini et vide sanitaire.' });
}
}
const drawerDepthMin = Number(settings.drawer_depth_min_mm || DRAWERS.minDepth);
const drawerDepthMax = Number(settings.drawer_depth_max_mm || DRAWERS.maxDepth);
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
const inferredEffectiveDrawerDepthMm = () => {
const rawGap = depthMm - NICHE.drawerBackGap;
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
const rawDt = config.hardware?.drawerType || 'visible';
const depthReductionMm =
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
return Math.max(350, steppedMm - depthReductionMm);
};
for (const [index, m] of modules.entries()) {
const isDrawerCol = (m.drawers || 0) > 0 || m.type === 'tiroirs';
if (isDrawerCol && Number(m.widthMm || 0) > DRAWERS.maxColumnWidth) {
errors.push({ field: `modules[${index}]`, message: `Colonne tiroirs > ${DRAWERS.maxColumnWidth} mm.` });
}
if (m.hasRod && heightMm < 1200) {
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
}
}
/**
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
*/
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
const effectiveDrawerMinMm = 350;
if (
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
canonicalEffectiveDrawerDepthMm > drawerDepthMax
) {
errors.push({
field: 'effectiveDrawerDepthMm',
message: `Profondeur tiroir utile hors bornes (${effectiveDrawerMinMm}-${drawerDepthMax} mm) pour cette profondeur caisse.`
});
}
const explicitEff = Number(config.effectiveDrawerDepthMm);
if (
Number.isFinite(explicitEff) &&
explicitEff > 0 &&
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
) {
warnings.push({
field: 'effectiveDrawerDepthMm',
message:
`Valeur stockée (${explicitEff} mm) différente du calcul attendu (${canonicalEffectiveDrawerDepthMm} mm) — le serveur utilise le calcul canonique.`
});
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || 1000);
const stdAxis = Number(settings.standard_handle_axis_mm || 1000);
const tolerance = Number(settings.standard_handle_tolerance_mm || 200);
if (handleAxis < stdAxis - tolerance || handleAxis > stdAxis + tolerance) {
warnings.push({ field: 'handleBoreHeightMm', message: 'Perçage poignée hors plage standard.' });
}
return { errors, warnings };
}
export default { validateConfig };
File diff suppressed because it is too large Load Diff
+89 -18
View File
@@ -64,40 +64,107 @@ export async function getDesignTip(config) {
/* -------------------------------------------------------------------------- */
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.
À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions.
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ègles :
- Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.).
- Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix.
- Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »).
- Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités.
Réponds UNIQUEMENT en JSON :
Réponds UNIQUEMENT en JSON valide :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
{
"finishId": <number>,
"summary": "2 phrases max : pourquoi ce choix au regard des priorités",
"criteria": {
"price": "phrase sur le rapport prix / gamme / accessibilité",
"quality": "phrase sur la qualité de finition, rendu, sensation",
"durability": "phrase sur tenue dans le temps, entretien, résistance",
"aesthetics": "phrase sur l'harmonie couleur/style avec le projet"
},
"reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)"
}
]
}`;
function clampPriority(n, fallback = 3) {
const x = Number(n);
if (!Number.isFinite(x)) return fallback;
return Math.min(5, Math.max(1, Math.round(x)));
}
/**
* @param {object} [p] - { price, quality, durability, aesthetics } each 1–5
*/
export function normalizeRecommendationPriorities(p) {
const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
if (!p || typeof p !== 'object') return { ...d };
return {
price: clampPriority(p.price, d.price),
quality: clampPriority(p.quality, d.quality),
durability: clampPriority(p.durability, d.durability),
aesthetics: clampPriority(p.aesthetics, d.aesthetics)
};
}
function normalizeMaterialRecommendation(rec) {
if (!rec || rec.finishId == null) return null;
const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {};
const summary = (rec.summary || rec.reason || '').trim();
const reason = (rec.reason || summary).trim();
return {
finishId: Number(rec.finishId),
summary,
reason,
criteria: {
price: String(criteria.price || '').trim(),
quality: String(criteria.quality || '').trim(),
durability: String(criteria.durability || '').trim(),
aesthetics: String(criteria.aesthetics || '').trim()
}
};
}
function buildPrioritiesBlock(priorities) {
const p = normalizeRecommendationPriorities(priorities);
return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) :
- Prix / rapport qualité-prix : ${p.price}
- Qualité perçue & finition : ${p.quality}
- Durabilité & entretien : ${p.durability}
- Esthétique & harmonie avec le projet : ${p.aesthetics}`;
}
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
* @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5
* @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const priBlock = buildPrioritiesBlock(priorities);
// Compact finish list (only names + ids to save tokens)
// Compact finish list (names + ids + optional price for fair "prix" explanations)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.map((f) => {
const name = f.name || f.label || '?';
const col = f.collection || '?';
const price =
f.price_chf != null && Number.isFinite(Number(f.price_chf))
? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF`
: '';
return `${f.id}: ${name} (${col})${price}`;
})
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
@@ -105,8 +172,8 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
temperature: 0.65,
max_tokens: 1400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
@@ -117,9 +184,13 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
const rawList = (parsed.recommendations || []).slice(0, 5);
const recommendations = rawList
.map(normalizeMaterialRecommendation)
.filter(Boolean);
return { recommendations, priorities: normalizeRecommendationPriorities(priorities) };
} catch {
return { recommendations: [] };
return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) };
}
}
+101
View File
@@ -0,0 +1,101 @@
import PDFDocument from 'pdfkit';
function addSectionTitle(doc, title) {
doc.moveDown(0.5);
doc.fontSize(14).font('Helvetica-Bold').text(title);
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica');
}
function ensurePageSpace(doc, neededHeight = 80) {
if (doc.y + neededHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
}
}
function keyValue(doc, key, value) {
doc.font('Helvetica-Bold').text(`${key}: `, { continued: true });
doc.font('Helvetica').text(String(value ?? '-'));
}
function tableRow(doc, cols, widths, opts = {}) {
const startX = doc.x;
const y = doc.y;
const lineHeight = opts.lineHeight || 14;
let x = startX;
for (let i = 0; i < cols.length; i++) {
const text = cols[i] == null ? '-' : String(cols[i]);
doc.text(text, x, y, {
width: widths[i],
lineBreak: false
});
x += widths[i];
}
doc.y = y + lineHeight;
}
export function generateOrderDossierPdf({ orderId, createdAt, customerName, config, cuttingList, packing }) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 40 });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(18).font('Helvetica-Bold').text(`Dossier technique commande #${orderId}`);
doc.fontSize(10).font('Helvetica').text(`Généré le ${new Date().toLocaleString('fr-CH')}`);
if (createdAt) doc.text(`Commande du ${new Date(createdAt).toLocaleString('fr-CH')}`);
if (customerName) doc.text(`Client: ${customerName}`);
addSectionTitle(doc, 'Configuration');
keyValue(doc, 'Dimensions', `${config.widthMm} x ${config.heightMm} x ${config.depthMm} mm`);
keyValue(doc, 'Installation', config.installationType === 'niche' ? 'Encastree' : 'Libre');
keyValue(doc, 'Portes', config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes');
keyValue(doc, 'Modules', config.moduleCount || 0);
addSectionTitle(doc, 'Liste de decoupage');
const cutWidths = [170, 130, 40, 130];
tableRow(doc, ['Piece', 'L x H x Ep.', 'Qte', 'Finition'], cutWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const piece of (cuttingList?.pieces || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
piece.description,
`${piece.widthMm}x${piece.heightMm}x${piece.thicknessMm}`,
piece.quantity,
piece.finishLabel || '-'
],
cutWidths
);
}
addSectionTitle(doc, 'Colisage');
const packWidths = [50, 170, 70, 70];
tableRow(doc, ['Colis', 'Niveau', 'Poids', 'Pieces'], packWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const box of (packing?.boxes || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
box.id,
box.level || '-',
`${Number(box.totalWeightKg || 0).toFixed(1)} kg`,
box.pieces?.length || 0
],
packWidths
);
}
doc.end();
});
}
File diff suppressed because it is too large Load Diff
+851
View File
@@ -0,0 +1,851 @@
import * as cheerio from 'cheerio';
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.haefele.ch';
const CRAWL_DELAY = parseInt(process.env.HAFELE_CRAWL_DELAY_MS || '500', 10);
const BROWSER_TIMEOUT_MS = parseInt(process.env.HAFELE_BROWSER_TIMEOUT_MS || '90000', 10);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeSpaces(value) {
return (value || '').replace(/\s+/g, ' ').trim();
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
function parsePriceCHF(text) {
const clean = normalizeSpaces(text)
.replace(/[\u00a0\u202f]/g, ' ')
.replace(/[’']/g, '');
const match = clean.match(/([\d]+(?:[.,][\d]+)?)\s*CHF|CHF\s*([\d]+(?:[.,][\d]+)?)/i);
if (!match) return null;
return parseFloat((match[1] || match[2]).replace(',', '.'));
}
function parseFirstMm(text) {
const match = normalizeSpaces(text).match(/(\d+(?:[.,]\d+)?)\s*mm/i);
if (!match) return null;
return parseFloat(match[1].replace(',', '.'));
}
function parseBoreDiameter(text) {
const clean = normalizeSpaces(text);
const patterns = [
/[Øø∅]\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(?:trou|per[çc]age|diam[eè]tre|boring|bohrung)\s*[:\s]*[Øø∅]?\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(\d+(?:[.,]\d+)?)\s*mm\s*(?:trou|per[çc]age|boring|bohrung)/i,
];
for (const pat of patterns) {
const m = clean.match(pat);
if (m) {
const val = parseFloat((m[1] || m[2]).replace(',', '.'));
if (val > 0 && val <= 50) return val;
}
}
return null;
}
function parseBoreDepths(specs) {
const baseKeys = [
'profondeur de perçage dans le fond',
'profondeur de perçage fond',
'profondeur perçage fond',
'profondeur percage fond',
'bohrtiefe in boden',
'bohrtiefe boden',
'drilling depth base',
'profondeur de perçage dans la partie inférieure',
];
const pieceKeys = [
'profondeur de perçage dans la pièce',
'profondeur de perçage pièce',
'profondeur perçage pièce',
'profondeur percage piece',
'profondeur de perçage dans le panneau latéral',
'profondeur de perçage dans la pièce fixée',
'bohrtiefe in seitenteil',
'bohrtiefe seitenteil',
'drilling depth piece',
'profondeur de perçage dans la partie latérale',
];
let depthBase = null;
let depthPiece = null;
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!depthBase) {
for (const bk of baseKeys) {
if (k.includes(bk)) { depthBase = parseFirstMm(value); break; }
}
}
if (!depthPiece) {
for (const pk of pieceKeys) {
if (k.includes(pk)) { depthPiece = parseFirstMm(value); break; }
}
}
if (depthBase && depthPiece) break;
}
// Fallback: look for a generic "profondeur de perçage" with two values
if (!depthBase && !depthPiece) {
for (const [key, value] of Object.entries(specs)) {
if (key.toLowerCase().includes('profondeur') && key.toLowerCase().includes('per')) {
const allMm = [...value.matchAll(/(\d+(?:[.,]\d+)?)\s*mm/gi)].map(m => parseFloat(m[1].replace(',', '.')));
if (allMm.length >= 2) {
depthBase = allMm[0];
depthPiece = allMm[1];
} else if (allMm.length === 1 && !depthBase) {
depthBase = allMm[0];
}
break;
}
}
}
return { depthBase, depthPiece };
}
function isCloudflareChallenge(html = '') {
const lower = normalizeSpaces(html).toLowerCase();
return lower.includes('just a moment') || lower.includes('cloudflare') || lower.includes('cf-challenge');
}
class HaefeleSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let rawCookies = [];
if (typeof response.headers.getSetCookie === 'function') {
rawCookies = response.headers.getSetCookie();
} else {
const raw = response.headers.get('set-cookie');
if (raw) {
rawCookies = raw.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
}
for (const header of rawCookies) {
const [pair] = header.split(';');
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
this.cookies.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
}
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
headers(extra = {}) {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
...(this.cookies.size > 0 ? { Cookie: this.cookieHeader() } : {}),
...extra,
};
}
async login(emit = () => {}, credentials = {}) {
const username = credentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = credentials.password || process.env.HAFELE_PASSWORD;
const customerNo = credentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
emit({ type: 'log', message: 'Identifiants Häfele absents (sync publique uniquement).' });
return false;
}
const homeUrl = `${BASE_URL}/fr/`;
const accountStartUrl = `${BASE_URL}/fr/account/start/`;
try {
// 1) Load FR home page (contains header login form + synchronizer token)
const getRes = await fetch(homeUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(getRes);
const html = await getRes.text();
const $ = cheerio.load(html);
// Prefer the known header login form from the provided markup.
let form = $('#loginWithExistingAccountForm').first();
if (!form.length) {
form = $('form').filter((_, el) => {
const id = ($(el).attr('id') || '').toLowerCase();
const action = ($(el).attr('action') || '').toLowerCase();
return id.includes('login') || action.includes('processlogin') || action.includes('/account/login');
}).first();
}
if (!form.length) {
emit({ type: 'log', message: 'Formulaire de login Häfele introuvable, continuation sans session.' });
return false;
}
const action = form.attr('action') || '/fr/account/login/';
const loginAction = absoluteUrl(action);
const payload = new URLSearchParams();
form.find('input').each((_, input) => {
const name = ($(input).attr('name') || '').trim();
const type = (($(input).attr('type') || 'text')).toLowerCase();
const value = ($(input).attr('value') || '').trim();
if (!name) return;
if (type === 'hidden') {
payload.set(name, value);
return;
}
const lowered = name.toLowerCase();
if (lowered.includes('pass')) {
payload.set(name, password);
} else if (lowered.includes('kunden') || lowered.includes('customer')) {
payload.set(name, customerNo);
} else if (lowered.includes('user') || lowered.includes('login') || lowered.includes('mitbenutzer')) {
payload.set(name, username);
}
});
// Explicit Häfele CH header login fields from current markup.
if (!payload.has('ShopLoginForm_Login_headerItemLogin')) {
payload.set('ShopLoginForm_Login_headerItemLogin', customerNo || username);
}
if (!payload.has('ShopLoginForm_Password_headerItemLogin')) {
payload.set('ShopLoginForm_Password_headerItemLogin', password);
}
if (!payload.has('ShopLoginForm_RememberLogin_headerItemLogin')) {
payload.set('ShopLoginForm_RememberLogin_headerItemLogin', 'on');
}
// Optional co-user login flow.
if (username && customerNo && username !== customerNo) {
payload.set('ShopLoginForm_LoginAsJointUser_headerItemLogin', 'on');
if (!payload.has('ShopLoginForm_UserName_headerItemLogin')) {
payload.set('ShopLoginForm_UserName_headerItemLogin', username);
}
}
const postRes = await fetch(loginAction, {
method: 'POST',
headers: this.headers({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: homeUrl,
Origin: BASE_URL,
}),
body: payload.toString(),
redirect: 'follow',
});
this.captureCookies(postRes);
// 2) Verify with account start page (authenticated area)
const checkRes = await fetch(accountStartUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(checkRes);
const finalUrl = checkRes.url || '';
const finalHtml = await checkRes.text();
const pageText = normalizeSpaces(finalHtml).toLowerCase();
const stillLogin =
finalUrl.includes('/account/login') ||
pageText.includes('mot de passe') ||
pageText.includes('passwort') ||
pageText.includes('connexion') && pageText.includes('numéro de client');
this.loggedIn = !stillLogin;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Häfele (${this.cookies.size} cookies).`
: 'Login Häfele non confirmé, continuation de la sync.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur login Häfele: ${err.message}` });
return false;
}
}
}
let session = new HaefeleSession();
let currentCredentials = {};
let currentEmit = () => {};
async function waitForCloudflare(page, maxSeconds = 30) {
for (let elapsed = 0; elapsed < maxSeconds; elapsed += 3) {
const title = await page.title().catch(() => '');
const html = await page.content().catch(() => '');
if (!isCloudflareChallenge(html) && !title.toLowerCase().includes('just a moment')) {
return true;
}
currentEmit({ type: 'log', message: ` Cloudflare en cours… (${elapsed}s)` });
await page.waitForTimeout(3000);
}
return false;
}
let browserInstance = null;
let browserContext = null;
async function getBrowserPage() {
if (browserInstance && browserContext) {
return browserContext.newPage();
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
throw new Error('Cloudflare challenge détecté et Playwright indisponible.');
}
const headless = process.env.HAFELE_BROWSER_HEADLESS !== '0';
browserInstance = await chromium.launch({
headless,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox'
]
});
browserContext = await browserInstance.newContext({
locale: 'fr-CH',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 }
});
return browserContext.newPage();
}
async function closeBrowser() {
if (browserInstance) {
await browserInstance.close().catch(() => {});
browserInstance = null;
browserContext = null;
}
}
let browserLoggedIn = false;
async function ensureBrowserLogin() {
if (browserLoggedIn) return;
const username = currentCredentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = currentCredentials.password || process.env.HAFELE_PASSWORD;
const customerNo = currentCredentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
currentEmit({ type: 'log', message: 'Pas d\'identifiants Häfele, navigation sans login.' });
return;
}
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: 'Ouverture de la page de login Häfele…' });
await page.goto(`${BASE_URL}/fr/account/login/`, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
currentEmit({ type: 'log', message: 'Cloudflare non résolu sur la page login.' });
const title = await page.title().catch(() => '?');
currentEmit({ type: 'log', message: ` Page titre: "${title}", URL: ${page.url()}` });
return;
}
const currentUrl = page.url();
const title = await page.title().catch(() => '');
currentEmit({ type: 'log', message: `Page login chargée: "${title}" (${currentUrl})` });
// Use exact ID selectors — the form has #ShopLoginForm_Login (text input)
// and #ShopLoginForm_LoginAsJointUser (hidden checkbox). Substring selectors
// like [name*=] would match the checkbox first and break .first().
const customerField = page.locator('#ShopLoginForm_Login');
const pwField = page.locator('#ShopLoginForm_Password');
try {
await customerField.waitFor({ state: 'visible', timeout: 15000 });
} catch {
currentEmit({ type: 'log', message: 'Champ #ShopLoginForm_Login non visible après attente.' });
const bodySnippet = normalizeSpaces(await page.content().catch(() => '')).slice(0, 500);
currentEmit({ type: 'log', message: ` Début HTML: ${bodySnippet}` });
return;
}
currentEmit({ type: 'log', message: 'Formulaire de login trouvé, saisie des identifiants…' });
await customerField.fill(customerNo || username);
await pwField.fill(password);
// Handle co-user mode if customer number differs from username.
if (username && customerNo && username !== customerNo) {
const coUserCheck = page.locator('#ShopLoginForm_LoginAsJointUser');
const coUserCheckDiv = page.locator('#divShopLoginForm_LoginAsJointUser');
// The real checkbox is hidden; the visible <div> acts as the toggle.
if (await coUserCheckDiv.count()) {
await coUserCheckDiv.click().catch(() => {});
await page.waitForTimeout(800);
} else if (await coUserCheck.count()) {
await page.evaluate(() => {
document.querySelector('#ShopLoginForm_LoginAsJointUser').checked = true;
document.querySelector('#ShopLoginForm_LoginAsJointUser').dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForTimeout(800);
}
const coUserName = page.locator('#ShopLoginForm_UserName');
if (await coUserName.isVisible({ timeout: 2000 }).catch(() => false)) {
await coUserName.fill(username);
}
}
const submitBtn = page.locator('button[data-testid="accountLoginFormBtn"]');
await Promise.all([
page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {}),
submitBtn.click()
]);
await page.waitForTimeout(3000);
const afterUrl = page.url();
browserLoggedIn = !afterUrl.includes('/account/login');
currentEmit({
type: 'log',
message: browserLoggedIn
? `Connecté à Häfele via navigateur (${afterUrl}).`
: `Login navigateur non confirmé (${afterUrl}), continuation sans prix.`
});
} finally {
await page.close();
}
}
async function fetchPageWithBrowser(url) {
await ensureBrowserLogin();
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: `Navigation vers ${url}…` });
await page.goto(url, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
throw new Error('Cloudflare challenge non résolu automatiquement dans le navigateur.');
}
// Wait for product content to appear.
try {
await page.locator('.productDetailPage, .productHeadline, h1').first()
.waitFor({ state: 'visible', timeout: 15000 });
} catch {
// Page loaded but no product selector found — continue anyway.
}
// Price is loaded via AJAX after page load — wait for it.
try {
await page.locator('#productPriceInformation span.price').first()
.waitFor({ state: 'visible', timeout: 15000 });
currentEmit({ type: 'log', message: 'Prix détecté dans la page.' });
} catch {
currentEmit({ type: 'log', message: 'Prix non chargé après 15s, récupération du contenu actuel.' });
}
const html = await page.content();
currentEmit({ type: 'log', message: 'Contenu récupéré via navigateur (Playwright).' });
return html;
} finally {
await page.close();
}
}
async function fetchPage(url) {
const referer = `${BASE_URL}/fr/`;
let response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
let firstBody = '';
if (response.status === 403) {
firstBody = await response.text().catch(() => '');
if (isCloudflareChallenge(firstBody)) {
currentEmit({ type: 'log', message: 'Cloudflare détecté, tentative de fallback navigateur…' });
return fetchPageWithBrowser(url);
}
}
// Retry once after refreshing home page cookies when anti-bot blocks first hit.
if (response.status === 403) {
const warmup = await fetch(referer, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(warmup);
response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
if (response.status === 403) {
const secondBody = await response.text().catch(() => '');
if (isCloudflareChallenge(secondBody)) {
currentEmit({ type: 'log', message: 'Challenge persistant, fallback navigateur…' });
return fetchPageWithBrowser(url);
}
throw new Error(`Failed to fetch ${url}: 403`);
}
}
if (!response.ok) {
const body = await response.text().catch(() => '');
const sample = normalizeSpaces(body).slice(0, 180);
throw new Error(`Failed to fetch ${url}: ${response.status}${sample ? ` (${sample})` : ''}`);
}
return response.text();
}
function extractKeyValueSpecs($) {
const specs = {};
$('.productPropertiesSection.details-product-body').each((_, el) => {
const key = normalizeSpaces($(el).find('.productPropertiesSectionHeader').first().text()).toLowerCase();
const value = normalizeSpaces($(el).find('.productPropertiesSectionBody').first().text());
if (key && value) specs[key] = value;
});
return specs;
}
function extractArticleNumber($) {
const text = normalizeSpaces($('.product-sku-title').first().text() || $.text());
const match = text.match(/N°\s*d['’]art\.\s*([\d.]+)/i);
return match ? match[1] : null;
}
function buildMaterialCode(url, articleNumber) {
if (articleNumber) {
return `HAF-${articleNumber.replace(/[^\dA-Za-z]/g, '')}`;
}
const skuMatch = url.match(/\/(P-\d+)\//i);
if (skuMatch) return `HAF-${skuMatch[1].toUpperCase()}`;
const slug = (url.split('/').filter(Boolean).pop() || 'unknown').replace(/[^\dA-Za-z]/g, '').slice(0, 30);
return `HAF-${slug.toUpperCase()}`;
}
function parseProductPage(html, url) {
const $ = cheerio.load(html);
const name = normalizeSpaces($('h1.productHeadline[itemprop="name"]').first().text() || $('h1.productHeadline').first().text());
const description = normalizeSpaces($('h2.productSubline[itemprop="description"]').first().text() || $('h2.productSubline').first().text());
const articleNumber = extractArticleNumber($);
const priceText =
normalizeSpaces($('#productPriceInformation .CUSTOMER_PRICE_Display .price span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation .pricedisplay:not(.hidden) span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation span.price').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.price').first().text()) ||
normalizeSpaces($('.priceContainer .price').first().text());
const priceCHF = parsePriceCHF(priceText);
if (priceText) {
currentEmit({ type: 'log', message: ` Prix trouvé: "${priceText}" → ${priceCHF} CHF` });
} else {
currentEmit({ type: 'log', message: ' Aucun prix trouvé dans le HTML.' });
}
const priceUnit = normalizeSpaces($('#productPriceInformation span.perUnit').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.perUnit').first().text()) || null;
const availabilityText = normalizeSpaces($('#productAvailabilityInformation .availability-flag').first().text());
const inStock = availabilityText
? (availabilityText.toLowerCase().includes('disponible') || availabilityText.toLowerCase().includes('stock')) &&
!availabilityText.toLowerCase().includes('indisponible')
: null;
// Deterministic image pick: prefer "primary" image, not the last diapo slide.
const pickImg = (sel) => {
const el = $(sel).first();
if (!el?.length) return null;
const src = el.attr('data-fullsrc') || el.attr('data-src') || el.attr('src') || '';
return absoluteUrl(String(src).trim());
};
// 1) Category/tile view markup (your snippet) — prefer when present.
const tileImg = pickImg('div.product_picture a.shopItemImage img, a.shopItemImage img');
// 2) OpenGraph is often the canonical hero image.
const ogImg = (
$('meta[property="og:image"]').attr('content') ||
$('meta[name="og:image"]').attr('content') ||
''
).trim();
const ogFull = absoluteUrl(ogImg);
// 3) Product detail hero image (if present).
const heroImg =
pickImg('.productImagePanelInner img[itemprop="image"]') ||
pickImg('.productImagePanelInner img');
// 4) First thumbnail (diapo) image.
const thumbImg = pickImg('.thumbsList img');
const imageUrl = tileImg || ogFull || heroImg || thumbImg || null;
const specs = extractKeyValueSpecs($);
if (Object.keys(specs).length > 0) {
const specsSummary = Object.entries(specs).map(([k, v]) => `${k}: ${v}`).join(' | ');
currentEmit({ type: 'log', message: ` Specs: ${specsSummary}` });
}
const category = normalizeSpaces($('.breadcrumb li:last-child').text()) || 'Hafele';
const thickness = parseFirstMm(specs['pour épaisseur du bois'] || specs['epaisseur'] || specs['épaisseur'] || '');
const allText = `${description} ${Object.values(specs).join(' ')}`;
const boreDiameter = parseBoreDiameter(allText);
const { depthBase, depthPiece } = parseBoreDepths(specs);
// Parse physical dimensions: diameter + length for cylindrical parts
let diameter = null;
let length = null;
const dimKeys = Object.keys(specs).map(k => k.toLowerCase());
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!diameter && (k.includes('diamètre') || k.includes('diametre') || k.includes('durchmesser') || k.includes('diameter'))) {
diameter = parseFirstMm(value);
}
if (!length && (k.includes('longueur') || k.includes('länge') || k.includes('length') || k.includes('lang'))) {
length = parseFirstMm(value);
}
}
// Fallback: parse "Ø 8/60" or "8/60" pattern from the product name (e.g. SC 8/60 = Ø8mm, 60mm long)
if (!diameter) {
const nameMatch = (name || '').match(/(\d+(?:[.,]\d+)?)\s*\/\s*(\d+(?:[.,]\d+)?)/);
if (nameMatch) {
const d = parseFloat(nameMatch[1].replace(',', '.'));
const l = parseFloat(nameMatch[2].replace(',', '.'));
if (d > 0 && d <= 30) diameter = d;
if (l > 0 && l <= 200 && !length) length = l;
}
}
return {
materialCode: buildMaterialCode(url, articleNumber),
articleNumber,
name: name || articleNumber || url,
description,
collection: 'Hafele',
category,
width_mm: null,
height_mm: null,
thickness_mm: thickness,
diameter_mm: diameter,
length_mm: length,
bore_diameter_mm: boreDiameter,
bore_depth_base_mm: depthBase,
bore_depth_piece_mm: depthPiece,
price_chf: priceCHF,
price_unit: priceUnit,
image_url: imageUrl,
formex_url: url,
in_stock: inStock,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['haefele', 'Hafele', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
return created.rows[0]?.id || null;
}
async function upsertMaterial(material, supplierId) {
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.materialCode]
);
if (existing.rows.length > 0) {
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
diameter_mm = COALESCE(?, diameter_mm), length_mm = COALESCE(?, length_mm),
bore_diameter_mm = COALESCE(?, bore_diameter_mm),
bore_depth_base_mm = COALESCE(?, bore_depth_base_mm),
bore_depth_piece_mm = COALESCE(?, bore_depth_piece_mm),
image_url = ?, formex_url = ?, price_chf = COALESCE(?, price_chf), price_unit = COALESCE(?, price_unit),
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
material.materialCode,
]
);
return existing.rows[0].id;
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return created.rows[0]?.id || null;
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['haefele', status, itemsCount, error]
);
}
export async function syncHaefeleByUrls(urls = [], emit = () => {}, options = {}) {
const credentials = options.credentials || {};
currentCredentials = credentials;
currentEmit = emit;
const validUrls = [...new Set((urls || [])
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean)
.filter((v) => v.includes('haefele.ch')))];
if (validUrls.length === 0) {
throw new Error('Aucune URL Häfele valide fournie.');
}
session = new HaefeleSession();
browserLoggedIn = false;
const supplierId = await ensureSupplier();
await session.login(emit, credentials);
let synced = 0;
let failed = 0;
const start = Date.now();
try {
emit({ type: 'log', message: `${validUrls.length} URL(s) Häfele à traiter.` });
emit({ type: 'progress', current: 0, total: validUrls.length, collection: 'Hafele' });
for (let i = 0; i < validUrls.length; i++) {
const url = validUrls[i];
emit({ type: 'log', message: `Traitement [${i + 1}/${validUrls.length}] ${url}` });
emit({ type: 'progress', current: i, total: validUrls.length, collection: 'Hafele' });
if (i > 0) await sleep(CRAWL_DELAY);
try {
const html = await fetchPage(url);
const parsed = parseProductPage(html, url);
await upsertMaterial(parsed, supplierId);
synced++;
const pricePart = parsed.price_chf != null ? ` - ${parsed.price_chf} CHF` : '';
const boreParts = [
parsed.bore_diameter_mm != null ? `Ø${parsed.bore_diameter_mm}` : null,
parsed.bore_depth_base_mm != null ? `fond:${parsed.bore_depth_base_mm}` : null,
parsed.bore_depth_piece_mm != null ? `pièce:${parsed.bore_depth_piece_mm}` : null,
].filter(Boolean);
const boreStr = boreParts.length ? ` [${boreParts.join(', ')}mm]` : '';
emit({ type: 'log', message: ` ✓ ${parsed.name}${pricePart}${boreStr}` });
} catch (err) {
failed++;
emit({ type: 'log', message: ` ✗ Erreur: ${err.message}` });
}
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
const status = failed > 0 && synced === 0 ? 'error' : 'success';
await logSync(status, synced, failed > 0 ? `${failed} url(s) failed` : null);
const result = {
success: status === 'success',
itemsCount: synced,
failedCount: failed,
duration,
error: status === 'error' ? 'Aucune URL Häfele n\'a pu être synchronisée.' : null
};
emit({ type: 'progress', current: validUrls.length, total: validUrls.length, collection: 'Hafele' });
if (result.success) emit({ type: 'complete', result });
else emit({ type: 'error', error: result.error });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
} finally {
await closeBrowser();
}
}
+478
View File
@@ -0,0 +1,478 @@
import pool from '../db/index.js';
import { generateCuttingList } from './cuttingEngine.js';
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
const HAEFELE_PRODUCT_URLS = {
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
back_panel_fastener_ixconnect_rpc_g_13_20:
'https://www.haefele.ch/fr/produkt/ferrure-d-assemblage-pour-paroi-arri-re-haefele-ixconnect-rpc-g-13-20-enficher-dans-la-rainure/P-00861578/',
};
function toMoney(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 0;
return Math.round(x * 100) / 100;
}
function addQty(map, key, row) {
const prev = map.get(key);
if (!prev) {
map.set(key, { ...row, quantity: Math.max(0, Number(row.quantity) || 0) });
return;
}
map.set(key, {
...prev,
quantity: (Number(prev.quantity) || 0) + (Number(row.quantity) || 0),
});
}
function pricePackSizeFromUnitLabel(unitLabel, unit) {
const u = String(unit || '').trim().toLowerCase();
if (u !== 'pc') return null;
const s = String(unitLabel || '').trim();
if (!s) return null;
// Examples we see in Häfele sync:
// - "par 100 Pièce (PC)"
// - "par 10 pièce"
// - "Par 25 PC"
// - "par 100 Pieces"
const m = s.match(/\bpar\s+(\d+)\s*(pi[eè]ce?s?|pc)\b/i);
const n = m ? Number(m[1]) : NaN;
if (!Number.isFinite(n) || n <= 1) return null;
return n;
}
async function fetchOne(sql, params) {
const r = await pool.query(sql, params);
return r?.rows?.[0] || null;
}
async function priceById(table, id) {
if (!id) return null;
const row = await fetchOne(`SELECT id, code, name, price_chf FROM ${table} WHERE id = ? AND active = 1`, [id]);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: toMoney(row.price_chf),
source: table,
};
}
async function findHaefeleMaterialForModel(model) {
const m = String(model || '').trim();
if (!m) return null;
// Prefer strict matches (code contains model OR name contains model)
// so we link to the exact Häfele product page when synced.
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (formex_code LIKE ? OR name LIKE ?)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[`%${m}%`, `%${m}%`]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
async function findHaefeleMaterialByUrl(url) {
const raw = String(url || '').trim();
if (!raw) return null;
// Normalize: strip query/hash (synced DB may store canonical URL).
const uNoHash = raw.split('#')[0];
const uBase = uNoHash.split('?')[0];
const u = uNoHash;
// Extract a stable numeric product id if present (e.g. ".../34242663/").
const m = uBase.match(/\/(\d{6,})\/?$/);
const productId = m?.[1] || null;
const candidates = [
u,
uBase,
].filter((v, idx, arr) => v && arr.indexOf(v) === idx);
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (
formex_url = ?
OR formex_url = ?
OR formex_url LIKE ?
OR (? IS NOT NULL AND formex_url LIKE ?)
)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[
candidates[0] || raw,
candidates[1] || raw,
`${uBase}%`,
productId,
productId ? `%/${productId}/%` : null,
]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
function computeCountsFromCuttingList(cuttingList) {
const items = new Map();
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
let doorCount = 0;
let drawersCount = 0;
for (const p of pieces) {
const qty = Math.max(0, Number(p?.quantity) || 0);
const m = p?.machining || {};
const wMm = Number(p?.widthMm) || 0;
if (p?.elementType === 'portes') {
doorCount += qty;
if (m.hingeCups?.count) {
addQty(items, 'hinge', {
key: 'hinge',
label: 'Charnière',
quantity: qty * (Number(m.hingeCups.count) || 0),
unit: 'pc',
});
}
// Assumption: 2 finish connectors per door (front/back).
if (m.finishConnectors?.model) {
addQty(items, `finish_connector:${m.finishConnectors.model}`, {
key: `finish_connector:${m.finishConnectors.model}`,
label: `Connecteur finition ${m.finishConnectors.model}`,
quantity: qty * 2,
unit: 'pc',
meta: { model: m.finishConnectors.model },
});
}
}
// Étagères amovibles — ferrures SP 15/8/5 (système ressort)
// User spec: 4 pieces per shelf/tablette.
if (p?.elementType === 'etageres' && m.mobileShelfSystem?.model === 'sp_15_8_5' && qty > 0) {
addQty(items, 'shelf_ferrure_sp_15_8_5', {
key: 'shelf_ferrure_sp_15_8_5',
label: 'Ferrure d’assemblage de tablettes SP 15/8/5 (ressort)',
quantity: qty * 4,
unit: 'pc',
meta: { model: 'SP 15/8/5' },
});
}
if (m.connectorHoles?.model && ['dessus', 'fond'].includes(p?.elementType)) {
const joints = Number(m.connectorHoles.jointsPerPiece) || (Array.isArray(m.connectorHoles.ixconnectJointsAlongDepth) ? m.connectorHoles.ixconnectJointsAlongDepth.length : 0);
const perJoint = Number(m.connectorHoles.connectorsPerJoint) || 1;
const tPer = Number(m.connectorHoles.tourillonsPerJoint) || 0;
addQty(items, `ixconnect:${m.connectorHoles.model}`, {
key: `ixconnect:${m.connectorHoles.model}`,
label: `Connecteur structure ${m.connectorHoles.model}`,
quantity: qty * joints * perJoint,
unit: 'pc',
meta: { model: m.connectorHoles.model },
});
if (tPer > 0) {
addQty(items, `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.connectorHoles.tourillonDiameter || 8}×30`,
quantity: qty * joints * tPer,
unit: 'pc',
});
}
}
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(p?.elementType)) {
const ferruresPerJoint = m.carcassFasteners.positionsAlongJointMm.length;
const jointCount = Number(m.carcassFasteners.jointCount) || (Array.isArray(m.carcassFasteners.junctionAxesAcrossWidthMm) ? m.carcassFasteners.junctionAxesAcrossWidthMm.length : 0);
const ferrures = qty * ferruresPerJoint * jointCount;
addQty(items, 'ferrure_carcasse', {
key: 'ferrure_carcasse',
label: `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`,
quantity: ferrures,
unit: 'pc',
productUrl: CARCASS_FASTENERS.productUrl,
meta: { model: CARCASS_FASTENERS.model },
});
// 2 dowels per ferrure (per spec text)
addQty(items, `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`, {
key: `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`,
label: `Tourillon Ø${m.carcassFasteners.dowelDiameter || 8}×${m.carcassFasteners.dowelDepth || 40}`,
quantity: ferrures * 2,
unit: 'pc',
});
}
if (p?.elementType === 'renforts' && m.reinforcementFixing?.connectorModel) {
const sideCount = Number(m.reinforcementFixing.sideCount) || 2;
const ixPerSide = Number(m.reinforcementFixing.ixconnectPerSide) || 1;
const tPerSide = Number(m.reinforcementFixing.tourillonsPerSide) || 0;
addQty(items, `reinforcement_ix:${m.reinforcementFixing.connectorModel}`, {
key: `reinforcement_ix:${m.reinforcementFixing.connectorModel}`,
label: `Connecteur renfort ${m.reinforcementFixing.connectorModel}`,
quantity: qty * sideCount * ixPerSide,
unit: 'pc',
meta: { model: m.reinforcementFixing.connectorModel },
});
if (tPerSide > 0) {
addQty(items, `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.reinforcementFixing.tourillonDiameter || 8}×30`,
quantity: qty * sideCount * tPerSide,
unit: 'pc',
});
}
}
if (m.blockAssembly?.middleCount && ['panneauxInterieurs', 'cotes'].includes(p?.elementType)) {
// Note: block assembly “vis de jonction” doesn't provide exact count per piece,
// so we do not count it here to avoid misleading totals.
}
if (p?.elementType === 'tiroirs') {
drawersCount += qty;
}
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
// Règle atelier (pour une longueur E = L ou H):
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
// - + 1 pièce tous les 30cm disponibles entre les 2 extrémités
// Donc: n(E) = 2 + floor(max(0, E-100) / 300) (E en mm)
if (p?.elementType === 'dos' && wMm > 0 && qty > 0) {
const hMm = Number(p?.heightMm) || 0;
const perEdgeW = 2 + Math.floor(Math.max(0, wMm - 100) / 300);
const perEdgeH = hMm > 0 ? 2 + Math.floor(Math.max(0, hMm - 100) / 300) : 0;
const totalPerPanel = perEdgeW * 2 + perEdgeH * 2;
const total = qty * totalPerPanel;
addQty(items, 'back_panel_fastener_ixconnect_rpc_g_13_20', {
key: 'back_panel_fastener_ixconnect_rpc_g_13_20',
label: 'Ferrure dos — Häfele Ixconnect RPC G 13/20 (rainure)',
quantity: total,
unit: 'pc',
productUrl: HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20,
meta: { model: 'Ixconnect RPC G 13/20' },
});
}
}
return { items: Array.from(items.values()), doorCount, drawersCount };
}
export async function generateHardwareList({ config, modules = [], colors = {} }) {
const cuttingList = generateCuttingList(config, colors, modules);
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
// Add plinth clips (simple deterministic rule)
const widthMm = Number(config?.widthMm) || 0;
if (widthMm > 0) {
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
if (clips > 0) {
items.push({
key: 'plinth_clips',
label: `Clips plinthe (${PLINTH.model})`,
quantity: clips,
unit: 'pc',
});
}
}
// Selected variants pricing (if configured)
const handleVariantId = config?.hardware?.handleVariantId;
const hingeVariantId = config?.hardware?.hingeVariantId;
const drawerSystemId = config?.hardware?.drawerSystemId;
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
priceById('handle_variants', handleVariantId),
priceById('hinge_variants', hingeVariantId),
priceById('blum_hardware', drawerSystemId),
]);
// Add handles as an item when selected
if (handleVariant && doorCount > 0) {
items.push({
key: `handle_variant:${handleVariant.id}`,
label: `Poignée — ${handleVariant.name}`,
quantity: doorCount,
unit: 'pc',
unitPriceChf: handleVariant.unitPriceChf,
source: handleVariant.source,
code: handleVariant.code,
});
}
// Attach hinge price to computed hinges (prefer synced Häfele, even without selected variant)
const hingeUrl = HAEFELE_PRODUCT_URLS.hinge_blum_clip_top_blumotion_110;
const haefeleHinge = await findHaefeleMaterialByUrl(hingeUrl);
for (const it of items) {
if (it.key !== 'hinge') continue;
if (hingeVariant) {
it.label = `Charnière — ${hingeVariant.name}`;
it.source = it.source ?? hingeVariant.source;
it.code = it.code ?? hingeVariant.code;
it.unitPriceChf = it.unitPriceChf ?? hingeVariant.unitPriceChf;
}
// Always attach product URL for the hinge line (useful for admins to sync/verify).
it.productUrl = it.productUrl ?? hingeUrl;
// Prefer synced Häfele data when available.
if (haefeleHinge) {
it.unitPriceChf = haefeleHinge.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleHinge.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleHinge.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleHinge.unitLabel;
it.code = haefeleHinge.code || it.code;
it.source = it.source ?? haefeleHinge.source;
}
}
// Attach carcass fastener (Ferrure corps / jonction) reference + price
const carcassUrl = CARCASS_FASTENERS.productUrl;
const haefeleCarcass = await findHaefeleMaterialByUrl(carcassUrl);
for (const it of items) {
if (it.key !== 'ferrure_carcasse') continue;
it.productUrl = it.productUrl ?? carcassUrl;
if (haefeleCarcass) {
it.unitPriceChf = haefeleCarcass.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleCarcass.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleCarcass.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleCarcass.unitLabel;
it.code = haefeleCarcass.code || it.code;
it.source = it.source ?? haefeleCarcass.source;
// Keep user-facing label stable but include model when present.
it.label = it.label || `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`;
}
}
// Attach back panel fastener (dos) reference + price
const backFastenerUrl = HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20;
const haefeleBackFastener = await findHaefeleMaterialByUrl(backFastenerUrl);
for (const it of items) {
if (it.key !== 'back_panel_fastener_ixconnect_rpc_g_13_20') continue;
it.productUrl = it.productUrl ?? backFastenerUrl;
if (haefeleBackFastener) {
it.unitPriceChf = haefeleBackFastener.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleBackFastener.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleBackFastener.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleBackFastener.unitLabel;
it.code = haefeleBackFastener.code || it.code;
it.source = it.source ?? haefeleBackFastener.source;
}
}
// Drawer system: assume priced “per drawer”
if (drawerSystem) {
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
if (totalDrawers > 0) {
items.push({
key: `drawer_system:${drawerSystem.id}`,
label: `Système tiroirs — ${drawerSystem.name}`,
quantity: totalDrawers,
unit: 'pc',
unitPriceChf: drawerSystem.unitPriceChf,
source: drawerSystem.source,
code: drawerSystem.code,
});
}
}
// Price Häfele items (best-effort by name match)
const haefeleLookups = new Map();
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
if (!haefeleLookups.has(model)) {
haefeleLookups.set(model, findHaefeleMaterialForModel(model));
}
}
const haefeleResolved = new Map();
await Promise.all(
Array.from(haefeleLookups.entries()).map(async ([model, prom]) => {
const r = await prom;
haefeleResolved.set(model, r);
})
);
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
const mat = haefeleResolved.get(model);
if (!mat) continue;
// For Häfele-connectors (SC 8/60, SC 8/25, SC 8/65...), prefer synced Häfele price when available.
it.unitPriceChf = mat.unitPriceChf ?? it.unitPriceChf;
it.source = it.source ?? mat.source;
it.code = it.code ?? mat.code;
it.productUrl = it.productUrl ?? mat.url;
it.imageUrl = it.imageUrl ?? mat.imageUrl;
it.unitLabel = it.unitLabel ?? mat.unitLabel;
}
// Compute totals
const normalized = items
.filter((it) => (Number(it.quantity) || 0) > 0)
.map((it) => {
let unitPriceChf = it.unitPriceChf == null ? null : toMoney(it.unitPriceChf);
const quantity = Math.max(0, Number(it.quantity) || 0);
let unitLabel = it.unitLabel || null;
// Häfele sometimes provides prices as "par 100 pièces" etc.
// Our UI + totals expect unitPriceChf to be per displayed `unit` (usually per pc).
if (unitPriceChf != null) {
const packSize = pricePackSizeFromUnitLabel(it.unitLabel, it.unit || 'pc');
if (packSize) {
unitPriceChf = toMoney(unitPriceChf / packSize);
unitLabel = 'par pièce (PC)';
}
}
const lineTotalChf = unitPriceChf == null ? null : toMoney(unitPriceChf * quantity);
return {
key: String(it.key),
label: String(it.label),
code: it.code || null,
quantity,
unit: it.unit || 'pc',
unitPriceChf,
lineTotalChf,
source: it.source || null,
productUrl: it.productUrl || null,
imageUrl: it.imageUrl || null,
unitLabel,
};
})
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const totalChf = toMoney(
normalized.reduce((s, it) => s + (Number(it.lineTotalChf) || 0), 0)
);
return { items: normalized, totalChf };
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Perçages par pièce pour exports CNC (.nc) et MPR (HOMAC/Woodwop XML-like).
* Même jeu de coordonnées que la vue liste / G-code commentaires : origine bas-gauche, X/Y en mm.
*/
import {
buildTopBottomPlanHoleSpecsMm,
buildVerticalPanelIxconnectFaceHoleSpecsMm,
buildShelfMobileHoleSpecsMm,
buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm
} from '../../shared/cncPanelHoles.js';
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
const specs = [];
function pushHole(params) {
specs.push(params);
}
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
if (m.system32?.positions?.length) {
const frontX = clampX(37);
const backX = clampX(widthMm - 37);
const d = Number(m.system32.holeDiameter || 5);
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
const throughHole = Boolean(m.system32.throughHole);
for (const pos of m.system32.positions) {
const y = clampY(pos);
pushHole({
xMm: frontX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
pushHole({
xMm: backX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
}
}
if (['dessus', 'fond'].includes(piece.elementType || '')) {
for (const spec of buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'etageres' || piece.elementType === 'rayonFixe') {
for (const spec of buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (piece.elementType === 'renfortsTiroir') {
for (const spec of buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'portes') {
for (const spec of buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind', face: 'face' });
}
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const dIx = Number(rf.edgeHole?.diameter || 8);
const dT = Number(rf.tourillonDiameter || 8);
const zIx = Number(rf.edgeHole?.depth || 45);
const zT = Number(piece.thicknessMm || 16);
const yFromTop = (mm) => clampY(heightMm - Number(mm));
for (const end of ['left', 'right']) {
const xEnd = end === 'left' ? 0 : widthMm;
if (Number.isFinite(Number(rf.ixconnectAxisFromTopMm))) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(rf.ixconnectAxisFromTopMm),
diameterMm: dIx,
depthMm: zIx,
type: 'ixconnect',
face: 'end',
end,
toolMode: 'edge'
});
}
for (const a of rf.tourillonAxesFromTopMm || []) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(a),
diameterMm: dT,
depthMm: zT,
type: 'tourillon',
face: 'end',
end,
toolMode: 'edge'
});
}
}
}
return { specs, thicknessMm };
}
+35
View File
@@ -0,0 +1,35 @@
import nodemailer from 'nodemailer';
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT) || 587;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error('SMTP configuration manquante (SMTP_HOST, SMTP_USER, SMTP_PASS)');
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
});
return transporter;
}
/**
* Send an email.
* @param {{ to: string, subject: string, html: string }} options
*/
export async function sendEmail({ to, subject, html }) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const t = getTransporter();
await t.sendMail({ from, to, subject, html });
}
+522
View File
@@ -0,0 +1,522 @@
/**
* Export MPR (WOODWOP / Homag-family .mpr) pour le débit APEX — un fichier par panneau, ZIP.
* Coordonnées XY = même repère que le G-code (.nc commentaires): origine bas-gauche, mm.
* Macros RSO/* : chemins exemple — à présence confirmée sur le poste/cellule.
*/
import JSZip from 'jszip';
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } from '../../shared/manufacturing.js';
const MPR_ARTICLE_CODES = Object.freeze({
plintheBas: 'PL_BAS',
bandeauHaut: 'BH',
dessus: 'BO_U',
fond: 'BO_L',
cotes: 'SE',
cotesDoublage: 'CD',
fileur: 'FI',
habillageFinitionGauche: 'FR_L_G',
habillageFinitionDroite: 'FR_L_D',
habillageFinitionDessus: 'FR_H',
dos: 'DO',
renforts: 'RE',
portes: 'TU',
panneauxInterieurs: 'ZA_O',
rayonFixe: 'RF_X',
etageres: 'EB_M',
renfortsTiroir: 'LABO'
});
function fmtMm(n) {
const v = Number(n);
if (!Number.isFinite(v)) return '0.0';
const rounded = Math.round(v * 10) / 10;
return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`;
}
function toSafe(value, fb) {
const n = Number(value);
return Number.isFinite(n) ? n : fb;
}
function escQuoted(s) {
return String(s ?? '').replace(/"/g, "'");
}
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
export function flattenPiecesForMprExport(pieces = []) {
const out = [];
for (const piece of pieces) {
const qty = Math.max(1, Math.round(toSafe(piece.quantity, 1)));
const splitSides =
piece.elementType === 'cotes' &&
qty === 2 &&
piece.id === 'piece-cotes' &&
String(piece.description || '').includes('gauche et droit');
if (splitSides) {
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_L', qtyIndex: 0 });
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_R', qtyIndex: 1 });
continue;
}
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
for (let qi = 0; qi < qty; qi++) {
let slug = baseSlug;
if (
qty > 1 &&
['panneauxInterieurs', 'renforts', 'etageres', 'rayonFixe', 'bandeauHaut'].includes(piece.elementType || '')
) {
slug = `${baseSlug}_${qi + 1}`;
}
out.push({ piece, slug, qtyIndex: qi });
}
}
return out;
}
function mprAnglesForPiece(piece) {
const et = piece.elementType || '';
if (habillageEtPorte(et)) {
return {
DRWIN: '270.0',
beschGedreht: '0',
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
};
}
if (['dessus', 'fond'].includes(et)) {
return {
DRWIN: '0.0',
beschGedreht: '1',
turnMsg: 'DREHEN Y-ACHSE'
};
}
return {
DRWIN: '0.0',
beschGedreht: '0',
turnMsg: 'DREHEN Y-ACHSE'
};
}
function habillageEtPorte(et) {
return (
['habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus', 'portes'].includes(
et
)
);
}
/** Kantendicken : habillages façon exemple FR_* (tous faces 1 mm). */
function kantenFlags(piece) {
const fin = habillageEtPorte(piece.elementType || '');
if (fin) {
return { kavo: '1.0', kali: '1.0', kahi: '1.0', kare: '1.0' };
}
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
}
function grooveSection(piece, _Lmm, _Bmm) {
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (!m.groove || !orient) return [];
const gw = fmtMm(Number(m.groove.width ?? GROOVE.back.width));
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
const win = vertical ? '180.0' : '270.0';
return [
'<121 \\Block\\',
'MNM="Kantenbearbeitungen von oben"',
'DP="1"',
'KO="04"',
'<139 \\Komponente\\',
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
'MNM="Nut Flaeche rueckseite"',
'XA="L"',
'YA="B"',
'VA="LA L"',
`VA="BR ${gw}"`,
`VA="TI ${gd}"`,
`VA="ABST ${abst}"`,
`VA="WIN ${win}"`,
'VA="ANF 0.0"',
'VA="END 0.0"',
'VA="MAT MAT"',
'KO="04"'
];
}
/** Un trou → lignes BohrVert (projection face Z+ ; perçages chant restent approximation LS/LSL). */
function bohrVertLines(spec, thicknessMm) {
const dRaw = Number(spec.diameterMm) || 5;
const depthRaw = Number(spec.depthMm) || 12;
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
const throughHint = Boolean(spec.toolMode === 'through');
const looksThrough = depthRaw >= thicknessMm - 0.5;
let bm = 'LS';
let ti;
if (throughHint || looksThrough) {
bm = 'LSL';
ti = `${fmtMm(thicknessMm)} + 1`;
} else if (edgeHint && depthRaw >= 38) {
bm = 'LSL';
ti = fmtMm(depthRaw);
} else {
ti = fmtMm(depthRaw);
}
const name = fmtMm(Math.round(dRaw * 10) / 10);
const du = fmtMm(Math.round(dRaw * 10) / 10);
const xa = fmtMm(spec.xMm);
const ya = fmtMm(spec.yMm);
return [
'<102 \\BohrVert\\',
`MNM="Bohrung ${name} mm"`,
`XA="${xa}"`,
`YA="${ya}"`,
`BM="${bm}"`,
`TI="${ti}"`,
`DU="${du}"`,
'AN="1"',
'MI="0"',
'S_="2"',
'ASG="2"',
'MLM="0"',
'KO="04"'
];
}
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
function dedupeHoleSpecs(specs, eps = 0.02) {
const out = [];
for (const s of specs) {
const hit = out.find(
(o) =>
Math.abs(o.xMm - s.xMm) < eps &&
Math.abs(o.yMm - s.yMm) < eps &&
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
);
if (!hit) out.push(s);
}
return out;
}
function buildSingleMprText({
piece,
slug,
qtyIndex,
seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
}) {
const angles = mprAnglesForPiece(piece);
const kant = kantenFlags(piece);
const L = toSafe(piece.rawCutWidthMm, piece.widthMm);
const B = toSafe(piece.rawCutHeightMm, piece.heightMm);
const D = toSafe(piece.thicknessMm, 19);
const posLabel = `${groupIndex}.${seqGlobal}`;
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
const unique = dedupeHoleSpecs(specs);
/* Bloc Bohrungen */
const holeLines = [];
for (const sp of unique) {
holeLines.push(...bohrVertLines(sp, thicknessMm));
}
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
const matKm = escQuoted(
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
);
const lines = [];
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"');
lines.push(
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
);
lines.push(
'[K',
'<00 \\Koordinatensystem\\',
'NR="04"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="0"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F1"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="90"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F2"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=90 OR DW=0 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+270"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F3"',
'XP="IF DW=0 OR DW=90 THEN 0 ELSE ld"',
'YP="IF DW=180 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+180"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F4"',
'XP="IF DW=180 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=270 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+90"',
'KI="90"',
'D2="0"',
'MI="0"',
'<100 \\WerkStck\\',
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
'DI="D"',
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
'AX="IF DW=90 OR DW=270 THEN ZUGVO+ZUGHI ELSE ZUGLI+ZUGRE"',
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
'<101 \\Kommentar\\',
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
`KM="POSITION : ${escQuoted(posLabel)}"`,
`KM="Bezeichnung: ${escQuoted(slug)}"`,
'KM=""',
`KM="${escQuoted(angles.turnMsg)}"`,
'KM=""',
'KM="Teileinfo"',
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
`KM="Staerke: ${fmtMm(D)}"`,
`KM=""`,
`KM="Material: ${matKm}"`,
'KM="Belag IN: "',
'KM="Belag AU: "',
'KM=""',
'<139 \\Komponente\\',
'MNM="Beschriftung"',
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
'VA="L L"',
'VA="B B"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
`VA="GEDREHT ${angles.beschGedreht}"`,
'VA="NCSTOPP 0"',
'KO="04"',
'<139 \\Komponente\\',
'MNM="Formatierung"',
'IN="RSO/Makro/RSO_Format.mpr"',
'VA="L L"',
'VA="B B"',
'VA="D D"',
'VA="WKZ 316"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
'VA="DIST 0.0"',
'VA="ECKRUND 0.1"',
'VA="KAFR 0"',
'VA="KAFRMAX 2.0"',
'VA="MAT MAT"',
'??="FORMAT"',
'KO="04"',
'<121 \\Block\\',
'MNM="Bearbeitungen von oben "',
`DP="${dp}"`,
'KO="04"'
);
lines.push(...holeLines);
lines.push(...grooveSection(piece, L, B));
lines.push('!');
return lines.join('\r\n');
}
function attachGlobalSeq(pieceFlat, allFlat) {
return pieceFlat.map((entry) => {
const globalIdx = allFlat.findIndex(
(f) =>
f.piece.id === entry.piece.id &&
f.qtyIndex === entry.qtyIndex &&
f.slug === entry.slug
);
return { ...entry, seqGlobal: globalIdx >= 0 ? globalIdx + 1 : 1 };
});
}
function buildMprFileRecords(entries, mprOptions) {
const { groupIndex = 1, orderReference = '', stackListKm = '01', defaultMaterialArticle = '' } = mprOptions;
return entries.map((entry) => {
const fileName = `${groupIndex}_${entry.seqGlobal}_${safeZipFileName(entry.slug)}.mpr`;
const content = buildSingleMprText({
piece: entry.piece,
slug: entry.slug,
qtyIndex: entry.qtyIndex,
seqGlobal: entry.seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return { fileName, content };
});
}
async function packMprRecords(records, zipBaseName) {
if (records.length === 1) {
const only = records[0];
return {
buffer: Buffer.from(only.content, 'utf8'),
fileName: only.fileName,
mimeType: 'application/octet-stream',
fileCount: 1
};
}
const zip = new JSZip();
for (const rec of records) {
zip.file(rec.fileName, rec.content);
}
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
return {
buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
fileName: `${safeBase}.zip`,
mimeType: 'application/zip',
fileCount: records.length
};
}
/** Nombre de fichiers MPR générés pour une ligne de débitage (ex. côtés L+R → 2). */
export function countMprInstancesForPiece(piece) {
return flattenPiecesForMprExport([piece]).length;
}
export async function generateMprZipBuffer({
config,
modules = [],
colors = {},
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
/** Index groupe commande dans le nom fichier (premier nombre). */
groupIndex = 1
}) {
const cuttingList = generateCuttingList(config, colors, modules);
const flat = flattenPiecesForMprExport(cuttingList.pieces);
const withSeq = attachGlobalSeq(flat, flat);
const records = buildMprFileRecords(withSeq, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return {
...(await packMprRecords(records, `apex-mpr-g${groupIndex}`)),
fileCount: records.length
};
}
/**
* Export MPR pour une seule ligne de la liste de débitage (identifiée par pieceId).
* @param {number|null|undefined} instanceIndex — exemplaire 0-based ; omis = tous les exemplaires de la ligne.
*/
export async function generateMprPieceDownload({
config,
modules = [],
colors = {},
pieceId,
instanceIndex,
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
groupIndex = 1
}) {
if (!pieceId || typeof pieceId !== 'string') {
throw new Error('pieceId requis.');
}
const cuttingList = generateCuttingList(config, colors, modules);
const piece = (cuttingList.pieces || []).find((p) => p.id === pieceId);
if (!piece) {
throw new Error('Pièce introuvable dans la liste de débitage.');
}
const allFlat = flattenPiecesForMprExport(cuttingList.pieces);
let pieceFlat = attachGlobalSeq(flattenPiecesForMprExport([piece]), allFlat);
if (instanceIndex != null && instanceIndex !== '') {
const idx = Math.floor(Number(instanceIndex));
if (!Number.isFinite(idx) || idx < 0) {
throw new Error('instanceIndex invalide.');
}
pieceFlat = pieceFlat.filter((entry) => entry.qtyIndex === idx);
if (pieceFlat.length === 0) {
throw new Error('Exemplaire introuvable pour cette pièce.');
}
}
const records = buildMprFileRecords(pieceFlat, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return packMprRecords(records, pieceId);
}
function safeZipFileName(seg) {
return String(seg).replace(/[^\w\-]+/g, '_').replace(/_+/g, '_');
}
export default {
generateMprZipBuffer,
generateMprPieceDownload,
countMprInstancesForPiece,
flattenPiecesForMprExport
};
+219
View File
@@ -0,0 +1,219 @@
/**
* Packing Engine — APEX 4.0
* Layer-based bin packing algorithm (per Dossier Technique Maître §3.1).
*
* Strategy:
* Level 1 — Côtés empilés (side panels stacked flat)
* Level 2 — Dessus + Fond côte à côte (top & bottom side by side)
* Level 3 — Étagères + Renforts groupés par épaisseur (shelves & reinforcements)
*
* Within each level, pieces are packed using First-Fit-Decreasing by weight,
* respecting a 28 kg max per box.
*/
import { PACKING, PANEL } from '../../shared/manufacturing.js';
/**
* Estimate the weight of a single piece in kg.
* Uses approximate melamine panel density by thickness.
* @param {number} widthMm
* @param {number} heightMm
* @param {number} thicknessMm
* @returns {number} weight in kg for ONE piece
*/
function estimatePieceWeight(widthMm, heightMm, thicknessMm) {
const volumeM3 = (widthMm / 1000) * (heightMm / 1000) * (thicknessMm / 1000);
let density;
if (thicknessMm >= PANEL.structure) {
density = PACKING.densityKgM3.melamine19;
} else if (thicknessMm >= PANEL.reinforcement) {
density = PACKING.densityKgM3.melamine16;
} else {
density = PACKING.densityKgM3.melamine8;
}
return volumeM3 * density;
}
/**
* Expand cutting list pieces into individual packable items.
* Each piece with quantity > 1 becomes multiple single items.
* @param {Array} pieces - pieces from cutting list
* @returns {Array} flat array of single items with weight
*/
function expandPieces(pieces) {
const items = [];
for (const piece of pieces) {
// Skip non-physical items (edge banding, etc.)
if (piece.unit === 'ml' || piece.widthMm === 0 || piece.heightMm === 0) continue;
const weightPerPiece = estimatePieceWeight(piece.widthMm, piece.heightMm, piece.thicknessMm);
for (let i = 0; i < piece.quantity; i++) {
items.push({
pieceId: piece.id,
elementType: piece.elementType,
description: piece.description,
widthMm: piece.widthMm,
heightMm: piece.heightMm,
thicknessMm: piece.thicknessMm,
quantity: 1,
weightKg: Math.round(weightPerPiece * 100) / 100,
index: i,
});
}
}
return items;
}
/**
* Classify expanded items into packing layers per APEX 4.0 §3.1.
*
* Level 1: cotes (side panels) — stacked
* Level 2: dessus + fond (top & bottom) — side by side
* Level 3: everything else grouped by thickness (etageres, renforts, panneauxInterieurs, dos, etc.)
*
* @param {Array} items - expanded single-piece items
* @returns {Object} { level1, level2, level3 } arrays
*/
function classifyByLayer(items) {
const level1 = []; // Côtés
const level2 = []; // Dessus + Fond
const level3 = []; // Étagères, Renforts, Séparateurs, Dos, Portes, etc.
for (const item of items) {
switch (item.elementType) {
case 'cotes':
case 'cotesDoublage':
level1.push(item);
break;
case 'dessus':
case 'fond':
level2.push(item);
break;
default:
level3.push(item);
break;
}
}
// Sort level 3 by thickness (group same thickness together) then by weight desc
level3.sort((a, b) => {
if (a.thicknessMm !== b.thicknessMm) return b.thicknessMm - a.thicknessMm;
return b.weightKg - a.weightKg;
});
return { level1, level2, level3 };
}
/**
* First-Fit-Decreasing bin packing for a set of items.
* Sort items by weight (heaviest first), then assign each to the
* first box that still has capacity.
*
* @param {Array} items - items to pack
* @param {number} startBoxId - starting box ID
* @param {string} levelLabel - label for these boxes (e.g. "Niveau 1")
* @returns {{ boxes: Array, nextBoxId: number }}
*/
function firstFitDecreasing(items, startBoxId = 1, levelLabel = '') {
if (items.length === 0) return { boxes: [], nextBoxId: startBoxId };
const sorted = [...items].sort((a, b) => b.weightKg - a.weightKg);
const maxWeight = PACKING.maxWeightKg;
const boxes = [];
for (const item of sorted) {
// If the single item exceeds max weight, it gets its own box
if (item.weightKg > maxWeight) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
continue;
}
// Try to fit in an existing box
let placed = false;
for (const box of boxes) {
if (box.totalWeightKg + item.weightKg <= maxWeight) {
box.pieces.push(item);
box.totalWeightKg = Math.round((box.totalWeightKg + item.weightKg) * 100) / 100;
placed = true;
break;
}
}
// Open a new box
if (!placed) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
}
}
return { boxes, nextBoxId: startBoxId + boxes.length };
}
/**
* Generate packing list from a cutting list result.
* Uses the APEX 4.0 layer-based strategy:
* Level 1 → sides stacked
* Level 2 → top + bottom side by side
* Level 3 → shelves + reinforcements grouped by thickness
*
* Each level is independently bin-packed with FFD, then all boxes
* are merged into a single sequential list.
*
* @param {Object} cuttingList - output of generateCuttingList()
* @returns {Object} packing summary
*/
export function generatePackingList(cuttingList) {
if (!cuttingList || !cuttingList.pieces || cuttingList.pieces.length === 0) {
return {
boxes: [],
totalBoxes: 0,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: 0,
totalWeightKg: 0,
strategy: 'layer-based (APEX 4.0)',
};
}
const items = expandPieces(cuttingList.pieces);
const { level1, level2, level3 } = classifyByLayer(items);
// Pack each level independently
const pack1 = firstFitDecreasing(level1, 1, 'Niveau 1 — Côtés');
const pack2 = firstFitDecreasing(level2, pack1.nextBoxId, 'Niveau 2 — Dessus/Fond');
const pack3 = firstFitDecreasing(level3, pack2.nextBoxId, 'Niveau 3 — Étagères/Renforts');
const allBoxes = [...pack1.boxes, ...pack2.boxes, ...pack3.boxes];
const heaviestBoxKg = allBoxes.reduce((max, b) => Math.max(max, b.totalWeightKg), 0);
const totalWeightKg = allBoxes.reduce((sum, b) => sum + b.totalWeightKg, 0);
return {
boxes: allBoxes,
totalBoxes: allBoxes.length,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: Math.round(heaviestBoxKg * 10) / 10,
totalWeightKg: Math.round(totalWeightKg * 10) / 10,
strategy: 'layer-based (APEX 4.0)',
layerSummary: {
level1: { label: 'Côtés empilés', boxes: pack1.boxes.length, items: level1.length },
level2: { label: 'Dessus + Fond', boxes: pack2.boxes.length, items: level2.length },
level3: { label: 'Étagères + Renforts', boxes: pack3.boxes.length, items: level3.length },
},
};
}
export default { generatePackingList };
+583 -183
View File
@@ -1,170 +1,113 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
* Includes APEX 4.0 manufacturing details: System 32 drilling, hinge
* positions, Ixconnect connectors, back panel groove, and reinforcements.
* Also handles PDF export.
*/
import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
} from '../../shared/manufacturing.js';
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
// ── Shared SVG styles ────────────────────────────────────────────────
const SHARED_STYLES = `
.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; }
.s32-hole { fill: none; stroke: #e63946; stroke-width: 0.4; }
.hinge-marker { fill: #ff6b35; stroke: #333; stroke-width: 0.3; }
.groove { fill: none; stroke: #2a9d8f; stroke-width: 1; stroke-dasharray: 2,2; }
.connector { fill: #6c5ce7; stroke: #333; stroke-width: 0.3; }
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
`;
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
* Draw System 32 hole column (vertical series of holes)
*/
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" />`;
}
function drawSystem32Holes(x, topY, panelHeightMm, scale) {
let svg = '';
const usable = panelHeightMm - SYSTEM32.firstHoleOffset;
const count = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * scale;
for (let i = 0; i < count; i++) {
const yOffset = (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * scale;
svg += `<circle cx="${x}" cy="${topY + yOffset}" r="${r}" class="s32-hole" />`;
}
// 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" />`;
}
return svg;
}
/**
* Draw hinge position markers on a door plan (côté charnières)
*/
function drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide = 'left') {
let svg = '';
const markerH = 6;
const markerW = 3;
const hingeCount = getHingeCountForDoor({ heightMm: doorHeightMm });
const positions = getHingeCupPositions(doorHeightMm, hingeCount);
const doorW = doorWidthMm * scale;
const cupInset = Math.min(22 * scale, doorW * 0.13);
const hingeLineX = hingeSide === 'right' ? doorX + doorW - cupInset : doorX + cupInset;
for (const posMm of positions) {
const y = doorY + posMm * scale;
svg += `<rect x="${hingeLineX - markerW / 2}" y="${y - markerH / 2}" width="${markerW}" height="${markerH}" class="hinge-marker" />`;
}
return svg;
}
/**
* Rainure poignée toute hauteur — côté opposé aux charnières, retrait H/B (HANDLE.fullHeightTopBottomOffset)
*/
function drawFullHeightHandleGrooveElevation(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide) {
const off = HANDLE.fullHeightTopBottomOffset;
const gw = HANDLE.fullHeightGroove.width;
const doorW = doorWidthMm * scale;
const axisX = hingeSide === 'right'
? doorX + (gw / 2) * scale
: doorX + doorW - (gw / 2) * scale;
const y1 = doorY + off * scale;
const y2 = doorY + doorHeightMm * scale - off * scale;
let svg = `<line x1="${axisX}" y1="${y1}" x2="${axisX}" y2="${y2}" class="fh-groove" />`;
const tx = axisX + (hingeSide === 'right' ? 5 : -5);
const anchor = hingeSide === 'right' ? 'start' : 'end';
svg += `<text x="${tx}" y="${doorY + doorHeightMm * scale * 0.45}" class="detail-label" text-anchor="${anchor}" fill="#0d9488">Rainure poignée TH (${gw} mm) · ${off} mm H/B</text>`;
return svg;
}
/**
* Draw back panel groove indicator (dashed line near panel edge).
* Per updated spec: groove at 16mm from panel edge.
*/
function drawGrooveIndicator(x, y, length, direction, scale) {
let svg = '';
const grooveOffset = GROOVE.back.axisFromPanelEdge * scale;
if (direction === 'vertical') {
svg += `<line x1="${x + grooveOffset}" y1="${y}" x2="${x + grooveOffset}" y2="${y + length}" class="groove" />`;
} 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;
}
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
}
// 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;
}
@@ -173,25 +116,366 @@ export function generateTopPlan(config, modules = []) {
*/
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;
}
// ── Plan generators ──────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * S;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// System 32 holes on left and right side panels
svg += drawSystem32Holes(oX + PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += drawSystem32Holes(oX + widthMm * S - PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * S;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const gapMm = 2;
const doorHmm = bodyHeight - 4;
for (let i = 0; i < blockCount; i++) {
const bw = items[i]?.widthMm || (widthMm / blockCount);
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
const modulePx = bw * S;
const doorWPx = doorWmm * S;
const doorX = currentX + (modulePx - doorWPx) / 2;
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
const hingeSide = getDoorHingeSide(i, blockCount, hw);
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
}
currentX += modulePx;
}
}
// Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm),
// third one centered if wardrobe height > 1500 mm.
const renfortX = oX + PANEL.structure * S;
const renfortW = Math.max(0, (widthMm - PANEL.structure * 2) * S);
const renfortH = PANEL.reinforcement * S;
const topRenfortY = oY + REINFORCEMENT.topBottomOffsetMm * S;
const bottomRenfortY = plinthY - REINFORCEMENT.topBottomOffsetMm * S - renfortH;
const reinforcementYs = [topRenfortY, bottomRenfortY];
if (heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm) {
reinforcementYs.push(oY + (bodyHeight * S) / 2 - renfortH / 2);
}
for (const y of reinforcementYs) {
svg += `<rect x="${renfortX}" y="${y}" width="${renfortW}" height="${renfortH}" class="renfort" />`;
}
svg += `<text x="${oX + (widthMm * S) / 2}" y="${svgHeight - 10}" class="detail-label" text-anchor="middle" fill="#6b5344" font-size="7.5">Renforts Forteresse (16 mm) : fixation par montant — ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords · axes sur plan pièce « Renforts »</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, widthMm * S, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(oX - 40, oY, heightMm * S, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view — includes groove indicator and System 32 holes
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = depthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Back panel (8mm) shown as a thin rectangle at the back
const backX = oX + (depthMm - PANEL.back) * S;
svg += `<rect x="${backX}" y="${oY + PANEL.structure * S}" width="${PANEL.back * S}" height="${(bodyHeight - PANEL.structure * 2) * S}" fill="#d4c4a4" stroke="#666" stroke-width="0.5" />`;
svg += `<text x="${backX - 2}" y="${oY + bodyHeight * S / 2}" class="detail-label" text-anchor="end">Dos ${PANEL.back}mm</text>`;
// Groove indicator (dashed green line at 16mm from panel edge)
const grooveAxisX = oX + (depthMm - GROOVE.back.axisFromPanelEdge) * S;
svg += `<line x1="${grooveAxisX}" y1="${oY + PANEL.structure * S}" x2="${grooveAxisX}" y2="${plinthY - PANEL.structure * S}" class="groove" />`;
svg += `<text x="${grooveAxisX - 3}" y="${oY + bodyHeight * S * 0.3}" class="detail-label" text-anchor="end" fill="#2a9d8f">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} (16mm du bord)</text>`;
// System 32 holes along the side panel
svg += drawSystem32Holes(oX + 37 * S, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += `<text x="${oX + 37 * S}" y="${oY - 5}" class="detail-label" text-anchor="middle" fill="#e63946">System 32</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, depthMm * S, `${depthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view — includes groove line for back panel
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = depthMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Body outline
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${depthMm * S}" class="body" />`;
// Back panel groove line (horizontal dashed line near panel edge)
const grooveY = oY + (depthMm - PANEL.structure) * S;
svg += `<line x1="${oX + PANEL.structure * S}" y1="${grooveY}" x2="${oX + (widthMm - PANEL.structure) * S}" y2="${grooveY}" class="groove" />`;
svg += `<text x="${oX + widthMm * S + 5}" y="${grooveY + 3}" class="detail-label" fill="#2a9d8f">Rainure dos</text>`;
// Module separators and labels
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * S;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${oY + depthMm * S / 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="${oY}" x2="${currentX}" y2="${oY + depthMm * S}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate SVG detail plan — zoomed view of a side panel showing
* System 32 drilling, connector holes, groove, and edge banding
*/
export function generateDetailPlan(config) {
const { depthMm, heightMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = config.widthMm - (PANEL.structure * 2);
const innerDepth = depthMm - PANEL.structure;
// Zoomed scale for detail view (3× normal)
const DS = 0.3;
// Show top 600mm of a side panel (enough for connectors, first S32 holes, groove)
const detailHeightMm = Math.min(600, bodyHeight);
const detailWidthMm = depthMm;
// Extra space on the right for shelf drilling inset
const svgWidth = detailWidthMm * DS + MARGIN * 2 + 260;
const svgHeight = detailHeightMm * DS + MARGIN * 2 + 40;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}
.edge-band { fill: #ff9f1c; stroke: none; opacity: 0.6; }
.callout { font-family: Arial, sans-serif; font-size: 8px; fill: #333; }
.callout-line { stroke: #999; stroke-width: 0.3; fill: none; }
.shelf-outline { fill: #f9fafb; stroke: #111827; stroke-width: 0.8; }
.shelf-face-hole { fill: none; stroke: #ea580c; stroke-width: 0.55; }
.shelf-edge-hole { fill: none; stroke: #7c3aed; stroke-width: 0.55; stroke-dasharray: 1.2 1.2; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="20" class="title" text-anchor="middle">Détail — Côté (vue intérieure)</text>`;
const oX = MARGIN + 40;
const oY = MARGIN + 20;
// Panel outline
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${detailHeightMm * DS}" class="body" stroke-width="2" />`;
// Edge banding (2mm ABS on all 4 edges)
const eb = EDGE_BANDING.abs * DS;
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // top
svg += `<rect x="${oX}" y="${oY + detailHeightMm * DS - eb}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // bottom
svg += `<rect x="${oX}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // left
svg += `<rect x="${oX + detailWidthMm * DS - eb}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // right
// Back panel groove — 16mm from panel edge
const grooveAxisOffset = (detailWidthMm - GROOVE.back.axisFromPanelEdge) * DS;
const grooveX = oX + grooveAxisOffset - (GROOVE.back.width / 2) * DS;
const grooveW = GROOVE.back.width * DS;
svg += `<rect x="${grooveX}" y="${oY + 5}" width="${grooveW}" height="${detailHeightMm * DS - 10}" fill="#2a9d8f" opacity="0.3" stroke="#2a9d8f" stroke-width="0.5" />`;
// Callout
svg += `<line x1="${grooveX + grooveW}" y1="${oY + 30}" x2="${grooveX + grooveW + 25}" y2="${oY + 20}" class="callout-line" />`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 22}" class="callout">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} mm</text>`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 31}" class="callout">Position à ${GROOVE.back.axisFromPanelEdge} mm du bord</text>`;
// System 32 holes (front row) — blind holes, depth 12mm
const s32X = oX + 37 * DS; // 37mm from front edge
const usable = detailHeightMm - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
const holeCount = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * DS;
for (let i = 0; i < Math.min(holeCount, 15); i++) {
const holeY = oY + (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * DS;
svg += `<circle cx="${s32X}" cy="${holeY}" r="${r}" class="s32-hole" />`;
}
// Callout for System 32
svg += `<line x1="${s32X}" y1="${oY + SYSTEM32.firstHoleOffset * DS}" x2="${oX - 15}" y2="${oY + SYSTEM32.firstHoleOffset * DS - 10}" class="callout-line" />`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 12}" class="callout" text-anchor="end">System 32 (borgne)</text>`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 3}" class="callout" text-anchor="end">Ø${SYSTEM32.holeDiameter} / prof. ${SYSTEM32.holeDepth} / pas ${SYSTEM32.pitch}</text>`;
// First hole offset dimension
svg += generateDimensionLine(oX + detailWidthMm * DS + 10, oY, SYSTEM32.firstHoleOffset * DS, `${SYSTEM32.firstHoleOffset}`, 'vertical');
// Ixconnect connector holes (top edge) — extrémité d’arête: T@20 → IX@50 → T@80 (vue détail côté)
const conn = CONNECTORS.ixconnect;
const pattern = CONNECTORS.jointPattern;
// Show 3 holes: tourillon@20, ixconnect@50, tourillon@80 from left edge (arête extrémité)
const jT1X = oX + pattern.tourillon1Axis * DS;
const jIXX = oX + pattern.ixconnectAxis * DS;
const jT2X = oX + pattern.tourillon2Axis * DS;
const tourR = (CONNECTORS.tourillonDiameter / 2) * DS;
const ixEdgeR = (conn.edgeHole.diameter / 2) * DS;
// Edge holes on top edge
svg += `<circle cx="${jT1X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 1
svg += `<circle cx="${jIXX}" cy="${oY}" r="${ixEdgeR}" class="connector" />`; // Ixconnect
svg += `<circle cx="${jT2X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 2
// Face holes (Ixconnect only — Ø8×12mm)
svg += `<circle cx="${jIXX}" cy="${oY + conn.faceHole.depth * DS}" r="${(conn.faceHole.diameter / 2) * DS}" class="connector" />`;
// Callout
svg += `<line x1="${jT2X + 5}" y1="${oY}" x2="${jT2X + 30}" y2="${oY - 15}" class="callout-line" />`;
svg += `<text x="${jT2X + 32}" y="${oY - 22}" class="callout">Ixconnect ${conn.model}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 13}" class="callout">About: Ø${conn.edgeHole.diameter}×${conn.edgeHole.depth} / Face: Ø${conn.faceHole.diameter}×${conn.faceHole.depth}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 4}" class="callout">Pattern: T@${pattern.tourillon1Axis} → IX@${pattern.ixconnectAxis} → T@${pattern.tourillon2Axis}</text>`;
// Panel thickness label
svg += `<text x="${oX + detailWidthMm * DS / 2}" y="${oY + detailHeightMm * DS + 15}" class="callout" text-anchor="middle">Épaisseur: ${PANEL.structure} mm — Chant ABS ${EDGE_BANDING.abs} mm</text>`;
// --- Inset: Rayons mobiles (Étagères amovibles) — SP 15/8/5 ---
// Dimensions de découpe : L.int - 2 mm / P.int - 5 mm
const shelfWmm = Math.max(80, innerWidth - 2);
const shelfDmm = Math.max(80, innerDepth - 5);
const insetMaxWpx = 170;
const insetMaxHpx = 190;
const insetScale = Math.min(insetMaxWpx / shelfWmm, insetMaxHpx / shelfDmm);
const insetX = oX + detailWidthMm * DS + 60;
const insetY = oY + 40;
const insetW = shelfWmm * insetScale;
const insetH = shelfDmm * insetScale;
svg += `<text x="${insetX + insetW / 2}" y="${insetY - 16}" class="callout" text-anchor="middle">Rayons mobiles — SP 15/8/5 (dessous)</text>`;
svg += `<rect x="${insetX}" y="${insetY}" width="${insetW}" height="${insetH}" class="shelf-outline" />`;
// Hole positions: from front 32mm; from back 37mm
const yFrontMm = 32;
const yBackMm = shelfDmm - 37;
const yPosMm = [yFrontMm, yBackMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
// Face holes: Ø20 axis 9.5mm from left/right
const xSideMm = 9.5;
const xPosMm = [xSideMm, shelfWmm - xSideMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const rFace = (20 / 2) * insetScale;
const rEdge = (8 / 2) * insetScale;
for (const yMm of yPosMm) {
const cy = insetY + yMm * insetScale;
// Face holes (underside) — left/right
for (const xMm of xPosMm) {
const cx = insetX + xMm * insetScale;
svg += `<circle cx="${cx}" cy="${cy}" r="${rFace}" class="shelf-face-hole" />`;
}
// Edge holes (against side panels) — left/right edge
svg += `<circle cx="${insetX}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
svg += `<circle cx="${insetX + insetW}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
}
// Callouts
svg += `<line x1="${insetX + insetW}" y1="${insetY + 10}" x2="${insetX + insetW + 22}" y2="${insetY}" class="callout-line" />`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 2}" class="callout">Face: Ø20×15 — axe 9.5 mm</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 12}" class="callout">Chant: Ø8×37 — à 6 mm du dessous</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 22}" class="callout">Avant: 32 mm · Arrière: 37 mm</text>`;
svg += '</svg>';
return svg;
}
@@ -203,8 +487,10 @@ export function generateAllPlans(config, modules = [], colors = {}) {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
detail: generateDetailPlan(config),
metadata: {
generatedAt: new Date().toISOString(),
specs: 'APEX 4.0',
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
@@ -220,14 +506,14 @@ export function generateAllPlans(config, modules = [], colors = {}) {
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
export function generatePDFHtml(config, modules = [], cuttingList = null, packingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<title>Plans Armoire — APEX 4.0</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
@@ -244,15 +530,19 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
.mfg-specs { background: #fff8e7; padding: 12px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #c9a96e; }
.mfg-specs h4 { margin: 0 0 8px; color: #8b7355; }
.mfg-specs ul { margin: 0; padding-left: 18px; }
.mfg-specs li { margin: 2px 0; }
</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>
<p class="subtitle">APEX 4.0 — 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">
@@ -264,25 +554,53 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="mfg-specs">
<h4>Normes de fabrication APEX 4.0</h4>
<ul>
<li>Structure ${PANEL.structure} mm / Renforts ${PANEL.reinforcement} mm / Dos ${PANEL.back} mm</li>
<li>Chants ABS ${EDGE_BANDING.abs} mm visible — Dos: ABS sur ${EDGE_BANDING.backPanelSides} chants</li>
<li>Rainure dos ${GROOVE.back.width}×${GROOVE.back.depth} mm — position à ${GROOVE.back.axisFromPanelEdge} mm du bord</li>
<li>System 32 côtés : Ø${SYSTEM32.holeDiameter} mm borgne, prof. ${SYSTEM32.holeDepth} mm, pas ${SYSTEM32.pitch} mm, offset ${SYSTEM32.firstHoleOffset} mm</li>
<li>System 32 séparateurs : Ø${SYSTEM32_SEPARATORS.holeDiameter} mm traversant, offset ${SYSTEM32_SEPARATORS.firstHoleOffset} mm</li>
<li>Connecteurs ${CONNECTORS.ixconnect.model} (structure) : about Ø${CONNECTORS.ixconnect.edgeHole.diameter}×${CONNECTORS.ixconnect.edgeHole.depth} / face Ø${CONNECTORS.ixconnect.faceHole.diameter}×${CONNECTORS.ixconnect.faceHole.depth}</li>
<li>Connecteurs ${CONNECTORS.ixconnectFinish.model} (finitions) : face Ø${CONNECTORS.ixconnectFinish.faceHole.diameter}×${CONNECTORS.ixconnectFinish.faceHole.depth}, axes ${CONNECTORS.ixconnectFinish.axes.back}/${CONNECTORS.ixconnectFinish.axes.front} mm</li>
<li>Ixconnect: extrémités d’arête T@${CONNECTORS.jointPattern.tourillon1Axis} → IX@${CONNECTORS.jointPattern.ixconnectAxis} → T@${CONNECTORS.jointPattern.tourillon2Axis} mm (depuis bord avant) · jonctions internes dessus/fond: IX au milieu profondeur + T ±${CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm} mm — faces séparateurs verticaux sans Ixconnect</li>
<li>Renforts : 1× ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords (axes selon largeur renfort)</li>
<li>Charnières ${HINGE.model} : axe à ${HINGE.axisOffset} mm du haut et du bas (réversible)</li>
<li>Matrice charnières: ≤900mm=2, 901-1600=3, 1601-2000=4, 2001-2400=5, 2401-2600=6</li>
<li>Étagères : ${SHELF_MACHINING.tourillonCount}× tourillons Ø${SHELF_MACHINING.tourillonDiameter} — ${SHELF_MACHINING.shelvesPerStratum} rayons/strate · Rayons mobiles SP 15/8/5 : face Ø20×15 (axe 9.5) + chant Ø8×37 (à 6 du dessous) · avant 32 / arrière 37</li>
<li>Plinthe : ${PLINTH.model} (pieds ${PLINTH.footHeight} mm + clips)</li>
<li>Dessus/Fond : ABS ${EDGE_BANDING.abs} mm avant ET arrière</li>
${config.hardware?.handleType === 'full_height' ? `<li>Poignée toute hauteur : déduction ${HANDLE.fullHeightWidthDeductionMm} mm en largeur porte ; rainure ${HANDLE.fullHeightGroove.width}×${HANDLE.fullHeightGroove.depth} mm côté opposé aux charnières ; retrait mécanique ${HANDLE.fullHeightTopBottomOffset} mm haut et bas</li>` : ''}
</ul>
</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>
<div class="page">
<div class="plan-container">
<div class="plan-title">Détail constructif — Côté</div>
${plans.detail}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
@@ -293,11 +611,11 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<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>Chant ABS 2mm:</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>
@@ -310,11 +628,15 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Usinage</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
const machStr = piece.machining
? Object.keys(piece.machining).join(', ')
: '-';
html += `
<tr>
<td>${piece.description}</td>
@@ -323,36 +645,114 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
<td>${machStr}</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th colspan="5">Sous-total</th>
<th>${group.pieceCount} pcs</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
// Piece-by-piece machining plan pages
for (const piece of (cuttingList.pieces || [])) {
if (!piece.piecePlanSvg) continue;
html += `
<div class="page">
<h2>Plan pièce - ${piece.description}</h2>
<p style="margin:0 0 8px;color:#555;">
Fini: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm
&nbsp;|&nbsp;
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
&nbsp;|&nbsp;
Qté: ${piece.quantity}
</p>
<div class="plan-container">
${piece.piecePlanSvg}
</div>
</div>`;
}
}
// Packing list page (layer-based per APEX 4.0)
if (packingList && packingList.boxes && packingList.boxes.length > 0) {
html += `
<div class="page">
<h2>Liste de colisage — Stratégie par niveaux (APEX 4.0)</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de colis:</span><strong>${packingList.totalBoxes}</strong></div>
<div class="spec-item"><span>Poids max/colis:</span><strong>${packingList.maxWeightKg} kg</strong></div>
<div class="spec-item"><span>Colis le plus lourd:</span><strong>${packingList.heaviestBoxKg.toFixed(1)} kg</strong></div>
<div class="spec-item"><span>Poids total:</span><strong>${packingList.totalWeightKg.toFixed(1)} kg</strong></div>
</div>
</div>`;
// Layer summary table
if (packingList.layerSummary) {
const ls = packingList.layerSummary;
html += `
<table>
<thead><tr><th>Niveau</th><th>Contenu</th><th>Colis</th><th>Pièces</th></tr></thead>
<tbody>
<tr><td>1</td><td>${ls.level1.label}</td><td>${ls.level1.boxes}</td><td>${ls.level1.items}</td></tr>
<tr><td>2</td><td>${ls.level2.label}</td><td>${ls.level2.boxes}</td><td>${ls.level2.items}</td></tr>
<tr><td>3</td><td>${ls.level3.label}</td><td>${ls.level3.boxes}</td><td>${ls.level3.items}</td></tr>
</tbody>
</table>`;
}
for (const box of packingList.boxes) {
html += `
<h3>Colis ${box.id}${box.level ? ` — ${box.level}` : ''} — ${box.totalWeightKg.toFixed(1)} kg</h3>
<table>
<thead>
<tr><th>Pièce</th><th>Dim. (L×H×Ép.)</th><th>Qté</th><th>Poids</th></tr>
</thead>
<tbody>`;
for (const item of box.pieces) {
html += `
<tr>
<td>${item.description}</td>
<td>${item.widthMm}×${item.heightMm}×${item.thicknessMm} mm</td>
<td>${item.quantity}</td>
<td>${item.weightKg.toFixed(1)} kg</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateDetailPlan,
generateAllPlans,
generatePDFHtml
generatePDFHtml
};
+89 -10
View File
@@ -1,15 +1,23 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
* Specs APEX 4.0 importées de shared/manufacturing.js
*/
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
function numberOr(value, fallback) {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* Épaisseur panneaux 19 mm (référence APEX 4.0).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
@@ -204,12 +212,13 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}
}
// Hinge costs - typically 3 per hinged door
// Hinge costs - dynamic quantity from APEX 4.0 matrix
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 doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
@@ -263,16 +272,22 @@ export function calculatePrice(config, data) {
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 kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
const standardHandleToleranceMm = numberOr(settings.standard_handle_tolerance_mm, 200);
const sanitaryGapMm = numberOr(settings.sanitary_gap_mm, NICHE.sanitaryGapAround);
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
columnMode: columnModeRaw
} = config;
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
@@ -286,11 +301,32 @@ export function calculatePrice(config, data) {
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' && !allowSlidingDoors) {
errors.push({ field: 'doorType', message: 'Les portes coulissantes sont désactivées pour le moment.' });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(widthMm - expectedW) > 2 || Math.abs(depthMm - expectedD) > 2 || Math.abs(heightMm - expectedH) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Les dimensions retenues ne correspondent pas aux plus petites mesures niche avec vide sanitaire de 3 cm.' });
}
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || standardHandleAxisMm);
const minStandard = standardHandleAxisMm - standardHandleToleranceMm;
const maxStandard = standardHandleAxisMm + standardHandleToleranceMm;
const isHandleAxisNonStandard = handleAxis < minStandard || handleAxis > maxStandard;
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
@@ -307,6 +343,13 @@ export function calculatePrice(config, data) {
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).` });
}
const hasDrawerColumnTooWide = modules.some((m) => ((m.drawers || 0) > 0 || m.type === 'tiroirs') && (m.widthMm || 0) > DRAWERS.maxColumnWidth);
if (hasDrawerColumnTooWide) {
errors.push({ field: 'modules', message: `Une colonne tiroirs dépasse ${DRAWERS.maxColumnWidth} mm (non autorisé).` });
}
if (modules.some((m) => m.hasRod) && heightMm < 1200) {
errors.push({ field: 'modules', message: 'Rayon avec tringle: hauteur minimale 1200 mm.' });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
@@ -386,6 +429,34 @@ export function calculatePrice(config, data) {
}
}
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
const columnCount = useModules
? Math.max(1, modules.length)
: Math.max(1, blocks.length || 1);
const verticalStructurePanelCount = columnMode === 'block'
? 2 * columnCount
: 2 + Math.max(0, columnCount - 1);
const reinforcementCount = verticalStructurePanelCount * 2;
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
: 0;
const structureUnitPriceM2 = finish
? (Number(finish.price_chf) || 0) / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000)
: 0;
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
const reinforcementUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) * 0.5 / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 0;
const reinforcementChf = Math.round(reinforcementSurfaceM2 * reinforcementUnitPriceM2 * 100) / 100;
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
@@ -393,7 +464,7 @@ export function calculatePrice(config, data) {
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
@@ -403,11 +474,19 @@ export function calculatePrice(config, data) {
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
reinforcementChf,
reinforcementCount,
columnMode,
verticalStructurePanelCount,
blockExtraStructureChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
warnings: isHandleAxisNonStandard
? [{ field: 'handleBoreHeightMm', message: `Hauteur perçage poignée hors plage standard (${minStandard}-${maxStandard} mm).` }]
: undefined,
errors: errors.length ? errors : undefined
};
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Supplier voucher service.
* After an order is placed, splits order items by supplier and sends each
* supplier an HTML email voucher listing the items they need to provide.
*/
import pool from '../db/index.js';
import { sendEmail } from './mailer.js';
import { getHingeCountForDoor } from '../../shared/manufacturing.js';
/**
* Resolve which supplier (if any) provides the finish/panel material.
*/
async function resolveFinishSupplier(finishId) {
if (!finishId) return null;
// Try formex_materials first (linked via finishes → formex_materials)
const { rows } = await pool.query(
`SELECT fm.id, fm.formex_code AS code, fm.name, fm.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM formex_materials fm
JOIN suppliers s ON s.id = fm.supplier_id
WHERE fm.id = $1 AND fm.active = 1 AND s.active = 1`,
[finishId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'panel' };
return null;
}
/**
* Resolve drawer system supplier.
*/
async function resolveBlumHardware(hardwareId, type) {
if (!hardwareId) return null;
const { rows } = await pool.query(
`SELECT bh.id, bh.code, bh.name, bh.price_chf, bh.type,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM blum_hardware bh
JOIN suppliers s ON s.id = bh.supplier_id
WHERE bh.id = $1 AND bh.active = 1 AND s.active = 1`,
[hardwareId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: type };
return null;
}
/**
* Resolve handle supplier.
*/
async function resolveHandle(handleId) {
if (!handleId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM handle_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[handleId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'handle' };
return null;
}
/**
* Resolve hinge supplier.
*/
async function resolveHinge(hingeId) {
if (!hingeId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM hinge_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[hingeId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'hinge' };
return null;
}
/**
* Estimate quantity for each item based on the order config.
*/
function estimateQuantity(category, config) {
const { modules = [], blocks = [], doorType, slidingPanelsCount } = config;
const itemCount = modules.length || blocks.length || 1;
switch (category) {
case 'panel':
return 1; // quantity expressed in panels by the price engine; we report 1 "lot"
case 'drawer_system':
return modules.reduce((sum, m) => sum + (m.drawers || 0), 0) || 1;
case 'hinge':
if (doorType === 'sliding') return 0;
return itemCount * getHingeCountForDoor({
heightMm: (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4
});
case 'handle':
return doorType === 'sliding'
? (slidingPanelsCount || 2)
: itemCount;
case 'runner':
case 'lift_system':
return 1;
default:
return 1;
}
}
/**
* Build an HTML voucher email body for a single supplier.
*/
function buildVoucherHtml({ orderId, orderDate, customerName, supplierName, items }) {
const formattedDate = new Date(orderDate).toLocaleDateString('fr-CH', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
const subtotal = items.reduce((sum, it) => sum + (it.unitPrice * it.qty), 0);
const rows = items.map(it => `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.code || '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.name}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center;">${it.qty}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'}</td>
</tr>`).join('');
return `<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>Bon de commande #${orderId}</title></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;color:#111827;">
<div style="max-width:680px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);">
<div style="background:#1e293b;color:#fff;padding:28px 32px;">
<h1 style="margin:0;font-size:20px;font-weight:700;">Bon de commande — APEX Configurateur</h1>
<p style="margin:6px 0 0;opacity:.75;font-size:14px;">Référence commande&nbsp;: <strong>#${orderId}</strong> &nbsp;|&nbsp; ${formattedDate}</p>
</div>
<div style="padding:24px 32px;">
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;width:140px;">Fournisseur</td>
<td style="padding:4px 0;font-weight:600;">${supplierName}</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;">Client</td>
<td style="padding:4px 0;">${customerName || '—'}</td>
</tr>
</table>
<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;color:#1e293b;">Articles à fournir</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Code</th>
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Désignation</th>
<th style="padding:8px 12px;text-align:center;font-weight:600;color:#374151;">Qté</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">PU (CHF)</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">Total</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="4" style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">Sous-total</td>
<td style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">${subtotal.toFixed(2)} CHF</td>
</tr>
</tfoot>
</table>
</div>
<div style="padding:16px 32px 24px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;">
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : [email protected]
</div>
</div>
</body>
</html>`;
}
/**
* Main entry point — called from orders route after insert.
* Splits the order by supplier and sends each supplier their voucher.
*
* @param {number} orderId
* @param {Object} config - the order config JSON (finishId, hardware, modules, blocks, doorType, …)
* @param {string} customerName
* @param {string} orderDate - ISO date string (defaults to now)
*/
export async function sendSupplierVouchers(orderId, config, customerName, orderDate = new Date().toISOString()) {
const suppliers = await buildSupplierVoucherPayload(config);
if (suppliers.length === 0) {
console.log(`[supplier-voucher] Commande #${orderId} : aucun fournisseur résolu, aucun email envoyé.`);
return;
}
// Send one email per supplier
const sends = [];
for (const supplier of suppliers) {
const html = buildVoucherHtml({
orderId,
orderDate,
customerName,
supplierName: supplier.supplierName,
items: supplier.items
});
sends.push(
sendEmail({
to: supplier.supplierEmail,
subject: `Bon de commande #${orderId} — APEX Configurateur`,
html
}).then(() => {
console.log(`[supplier-voucher] Email envoyé à ${supplier.supplierName} <${supplier.supplierEmail}> pour commande #${orderId}`);
})
);
}
await Promise.all(sends);
}
/**
* Build supplier payload from order config.
* Used by email sender and by admin API preview.
*
* @param {Object} config
* @returns {Promise<Array<{supplierId:number, supplierCode:string|null, supplierName:string, supplierEmail:string, items:Array, subtotal:number}>>}
*/
export async function buildSupplierVoucherPayload(config) {
const { hardware = {} } = config;
const { handleVariantId, hingeVariantId, drawerSystemId } = hardware;
// Resolve all items in parallel
const [finishResult, drawerResult, handleResult, hingeResult] = await Promise.all([
resolveFinishSupplier(config.finishId),
resolveBlumHardware(drawerSystemId, 'drawer_system'),
resolveHandle(handleVariantId),
resolveHinge(hingeVariantId)
]);
// Group items by supplier_id
const supplierMap = new Map(); // supplier_id → { supplier info, items[] }
for (const result of [finishResult, drawerResult, handleResult, hingeResult]) {
if (!result) continue;
const { supplier, item, category } = result;
const sid = supplier.supplier_id;
if (!supplierMap.has(sid)) {
supplierMap.set(sid, {
code: supplier.supplier_code || null,
name: supplier.supplier_name,
email: supplier.supplier_email,
items: []
});
}
const qty = estimateQuantity(category, config);
if (qty > 0) {
supplierMap.get(sid).items.push({
code: item.code,
name: item.name,
unitPrice: item.price_chf,
qty,
category
});
}
}
return Array.from(supplierMap, ([supplierId, supplierData]) => {
const subtotal = supplierData.items.reduce((sum, item) => sum + ((item.unitPrice || 0) * item.qty), 0);
return {
supplierId,
supplierCode: supplierData.code || null,
supplierName: supplierData.name,
supplierEmail: supplierData.email,
items: supplierData.items,
subtotal
};
});
}