@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
+2717
-86
File diff suppressed because it is too large
Load Diff
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+630
-201
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
|
||||
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
|
||||
|
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 : <strong>#${orderId}</strong> | ${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
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user