feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+221 -29
View File
@@ -43,6 +43,9 @@
.btn-calc:hover { background: #1F5438; }
.btn-reset { background: #e0e0e0; color: #444; border: none; border-radius: 6px; padding: 11px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; }
.btn-reset:hover { background: #d0d0d0; }
.btn-pdf { background: #1F5438; color: #fff; border: none; border-radius: 5px; padding: 9px 16px; font-size: 12px; font-weight: 700; cursor: pointer; margin-top: 13px; }
.btn-pdf:hover:not(:disabled) { background: #153c28; }
.btn-pdf:disabled { cursor: not-allowed; opacity: 0.48; }
.result-box { background: linear-gradient(135deg, #D6EBE2 0%, #8CC9AB 100%); border: 2px solid #2BAC70; border-radius: 8px; padding: 16px 20px; margin-bottom: 18px; display: none; }
.result-box.show { display: block; }
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
@@ -469,7 +472,7 @@
<!-- DIRECT CALCULATION TAB -->
<div class="tab-pane active" id="tab-direct">
<div class="form-row">
<div class="form-group">
<label>Product Category</label>
@@ -509,10 +512,9 @@
<span class="unit-label">mm</span>
</div>
<div class="core-presets">
<button class="core-preset-btn" data-core="133">133mm Stahl</button>
<button class="core-preset-btn" data-core="150">150mm PVC</button>
<button class="core-preset-btn" data-core="168">168mm PVC</button>
<button class="core-preset-btn" data-core="194">194mm Stahl</button>
{% for preset in core_presets %}
<button class="core-preset-btn" data-core="{{ preset.ui_diameter }}" data-core-material="{{ preset.material }}">{{ preset.label }}</button>
{% endfor %}
</div>
</div>
<div class="form-group">
@@ -613,6 +615,7 @@
<div class="result-label" id="d-res-label">Result</div>
<div class="result-value" id="d-res-val">–</div>
<div class="result-range" id="d-res-range"></div>
<button class="btn-pdf" id="d-pdf" type="button" disabled>Download PDF</button>
</div>
<div class="formula-box">
@@ -880,6 +883,8 @@
// =====================================================
window.ARTICLE_DATA = [];
window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}};
window.CURRENT_ROLL_REQUEST = null;
window.CURRENT_ROLL_RESULT = null;
// =====================================================
// LOAD ARTICLE DATA
@@ -936,6 +941,7 @@ function populateArticleDropdowns() {
inp.value = val;
pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (pane.id === 'tab-direct') invalidatePdfReport();
}
}
});
@@ -982,7 +988,102 @@ document.querySelectorAll('.mode-btn').forEach(btn => {
// =====================================================
// DIRECT CALCULATION (WITH 2σ)
// =====================================================
document.getElementById('d-calc').addEventListener('click', () => {
function currentArticle() {
const rawValue = document.getElementById('d-art').value.trim();
if (!rawValue) return null;
const match = (window.ARTICLE_DATA || []).find(article =>
rawValue === article.nr || rawValue.startsWith(article.nr + ' –')
);
if (match) return { number: String(match.nr), name: match.name || '' };
const separator = rawValue.indexOf(' – ');
if (separator >= 0) {
return { number: rawValue.slice(0, separator), name: rawValue.slice(separator + 3) };
}
return { number: rawValue, name: '' };
}
function currentCoreType(coreDiameter) {
const activePreset = document.querySelector('#tab-direct .core-preset-btn.active');
if (activePreset && Number(activePreset.dataset.core) === coreDiameter) return activePreset.textContent.trim();
return 'Custom / not specified';
}
function invalidatePdfReport() {
window.CURRENT_ROLL_REQUEST = null;
window.CURRENT_ROLL_RESULT = null;
const button = document.getElementById('d-pdf');
button.disabled = true;
button.textContent = 'Download PDF';
}
function numericInput(id) {
const raw = document.getElementById(id).value.trim();
return raw === '' ? null : Number(raw);
}
function buildCalculationRequest() {
const article = currentArticle();
const width = numericInput('d-wi');
const areaWeight = numericInput('d-aw');
return {
intent: 'calculate_roll_diameter',
article_number: article?.number || null,
article_name_hint: article?.name || null,
roll_length_m: numericInput('d-le'),
width_m: width,
thickness_mm: numericInput('d-th'),
thickness_stddev_mm: numericInput('d-tol'),
area_weight_g_m2: areaWeight,
core_diameter_mm: numericInput('d-co'),
core_type: currentCoreType(numericInput('d-co')),
category: document.getElementById('d-cat').value || null,
production_site: document.getElementById('d-site').value || null,
include_roll_weight: width !== null && areaWeight !== null
};
}
function calculationIssueMessage(result) {
if (result.status === 'article_not_found') return 'Article not found.';
if (result.status === 'article_conflict') return 'Article number and name conflict.';
if (result.status === 'needs_clarification') {
const fields = [...(result.missing || []), ...(result.ambiguous || [])];
return 'Please clarify: ' + fields.join(', ');
}
const issue = result.invalid?.[0]?.message;
return issue ? 'Invalid calculation request: ' + issue : 'Calculation failed.';
}
function renderAuthoritativeForkliftResult(result) {
const box = document.getElementById('forkliftCheck');
const titleEl = document.getElementById('forkliftCheckTitle');
const bodyEl = document.getElementById('forkliftCheckBody');
const warning = result.warnings?.[0];
const note = result.notes?.[0];
if (warning) {
box.className = 'forklift-check ' + (warning.severity === 'critical' ? 'fc-block' : 'fc-warn') + ' show';
titleEl.textContent = '⚠️ ' + warning.title;
bodyEl.textContent = warning.message;
} else if (note) {
box.className = 'forklift-check fc-ok show';
titleEl.textContent = '✓ Forklift Check OK';
bodyEl.textContent = note;
} else {
box.classList.remove('show');
}
}
async function requestRollCalculation(calculationRequest) {
const response = await fetch('/api/calculations/roll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(calculationRequest)
});
if (!response.ok) throw new Error('Calculation service failed');
return response.json();
}
document.getElementById('d-calc').addEventListener('click', async () => {
invalidatePdfReport();
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
const d = parseFloat(document.getElementById('d-co').value) || 0;
const t = parseFloat(document.getElementById('d-th').value) || 0;
@@ -995,20 +1096,45 @@ document.getElementById('d-calc').addEventListener('click', () => {
const resLabel = document.getElementById('d-res-label');
const resVal = document.getElementById('d-res-val');
const resRange = document.getElementById('d-res-range');
let diameterResult = null;
let authoritativeResult = null;
let calculationRequest = null;
if (mode === 'diameter') {
if (!d || !t || !L) { alert('Please enter d, t, and L.'); return; }
const D_nom = Math.sqrt(d*d + (4*L*1000*t)/Math.PI);
calculationRequest = buildCalculationRequest();
try {
authoritativeResult = await requestRollCalculation(calculationRequest);
} catch (error) {
alert(error.message);
return;
}
if (authoritativeResult.status !== 'success') {
alert(calculationIssueMessage(authoritativeResult));
return;
}
const calculation = authoritativeResult.calculation;
diameterResult = {
minimum: calculation.minimum_diameter_mm,
average: calculation.average_diameter_mm,
maximum: calculation.maximum_diameter_mm,
minus2Sigma: calculation.minimum_diameter_mm,
plus2Sigma: calculation.maximum_diameter_mm,
rollLength: calculation.effective_roll_length_m
};
resLabel.textContent = 'Roll Diameter (D)';
resVal.textContent = D_nom.toFixed(1) + ' mm';
resVal.textContent = diameterResult.average.toFixed(1) + ' mm';
if (tol) {
const D_minus2sigma = Math.sqrt(d*d + (4*L*1000*(t-2*tol))/Math.PI);
const D_plus2sigma = Math.sqrt(d*d + (4*L*1000*(t+2*tol))/Math.PI);
resRange.textContent = '−2σ: ' + D_minus2sigma.toFixed(1) + ' mm | +2σ: ' + D_plus2sigma.toFixed(1) + ' mm';
resRange.textContent = '−2σ: ' + diameterResult.minus2Sigma.toFixed(1) + ' mm | +2σ: ' + diameterResult.plus2Sigma.toFixed(1) + ' mm';
} else {
resRange.textContent = '';
}
document.getElementById('d-di').value = D_nom.toFixed(1);
document.getElementById('d-di').value = diameterResult.average.toFixed(1);
if (authoritativeResult.effective_inputs.width_m?.source === 'article_master_data') {
document.getElementById('d-wi').value = authoritativeResult.effective_inputs.width_m.value;
}
const authoritativeWeight = calculation.roll_weight_kg;
document.getElementById('d-wo').value = authoritativeWeight === null ? '' : authoritativeWeight.toFixed(1);
renderAuthoritativeForkliftResult(authoritativeResult);
} else if (mode === 'length') {
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
@@ -1074,17 +1200,24 @@ document.getElementById('d-calc').addEventListener('click', () => {
document.getElementById('d-wo').value = W_result.toFixed(1);
}
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
if (wi > 0 && aw > 0 && L_final > 0) {
const weight = aw * L_final * wi / 1000;
document.getElementById('d-wo').value = weight.toFixed(1);
checkForklift(weight, d, category);
} else {
document.getElementById('forkliftCheck').classList.remove('show');
if (mode !== 'diameter') {
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
if (wi > 0 && aw > 0 && L_final > 0) {
const rollWeight = aw * L_final * wi / 1000;
document.getElementById('d-wo').value = rollWeight.toFixed(1);
checkForklift(rollWeight, d, category);
} else {
document.getElementById('forkliftCheck').classList.remove('show');
}
}
if (mode === 'diameter' && authoritativeResult) {
window.CURRENT_ROLL_REQUEST = calculationRequest;
window.CURRENT_ROLL_RESULT = authoritativeResult;
document.getElementById('d-pdf').disabled = false;
}
resultBox.style.display = 'block';
});
@@ -1096,6 +1229,7 @@ document.getElementById('d-reset').addEventListener('click', () => {
document.getElementById('d-art').value = '';
document.getElementById('d-result').style.display = 'none';
document.getElementById('forkliftCheck').classList.remove('show');
invalidatePdfReport();
});
// =====================================================
@@ -1141,33 +1275,91 @@ function checkForklift(weightKg, coreMm, category) {
const cfg = window.APP_CONFIG?.forklift_rules;
if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) {
box.classList.remove('show');
return;
return null;
}
const rule = cfg.rules[0];
const limit = cfg.hard_limit;
if (weightKg > limit.max_weight_kg) {
const message = limit.messages.en.block_text.replace('{weight}', weightKg);
box.className = 'forklift-check fc-block show';
titleEl.textContent = '⚠️ ' + limit.messages.en.block_title;
bodyEl.innerHTML = '<p>' + limit.messages.en.block_text.replace('{weight}', weightKg) + '</p>';
return;
bodyEl.innerHTML = '<p>' + message + '</p>';
return {
kind: 'warning',
severity: 'critical',
title: limit.messages.en.block_title,
message
};
}
if (weightKg >= rule.weight_threshold_kg) {
const coreOk = coreMm >= rule.requirements.min_core_outer_diameter_mm;
const coreMessage = coreOk
? 'Core diameter OK'
: 'Core diameter ' + coreMm + ' mm (min 170 mm required)';
const message = 'Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg. ' + coreMessage + '.';
box.className = 'forklift-check fc-warn show';
titleEl.textContent = '⚠️ ' + rule.messages.en.warning_title;
const statusMsg = coreOk ? '<li class="fc-req-ok">✓ Core diameter OK</li>' : '<li class="fc-req-nok">✗ Core diameter ' + coreMm + ' mm (min 170 mm required)</li>';
bodyEl.innerHTML = '<p>Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg:</p><ul>' + statusMsg + '</ul>';
return;
bodyEl.innerHTML = '<p>' + message + '</p>';
return {
kind: 'warning',
severity: 'warning',
title: rule.messages.en.warning_title,
message
};
}
const message = 'Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.';
box.className = 'forklift-check fc-ok show';
titleEl.textContent = '✓ Forklift Check OK';
bodyEl.innerHTML = '<p>Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.</p>';
bodyEl.innerHTML = '<p>' + message + '</p>';
return { kind: 'note', message };
}
document.getElementById('tab-direct').addEventListener('input', event => {
if (event.target.id !== 'd-wo') invalidatePdfReport();
});
document.getElementById('tab-direct').addEventListener('change', invalidatePdfReport);
document.querySelectorAll('.mode-btn').forEach(button => button.addEventListener('click', invalidatePdfReport));
document.getElementById('d-pdf').addEventListener('click', async () => {
const button = document.getElementById('d-pdf');
if (!window.CURRENT_ROLL_REQUEST || button.disabled) return;
button.disabled = true;
button.textContent = 'Creating PDF…';
try {
const response = await fetch('/api/reports/roll-calculation.pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(window.CURRENT_ROLL_REQUEST)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || 'PDF generation failed');
}
const disposition = response.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1].replace(/"$/, '')) : 'roll-calculation.pdf';
const url = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
alert(error.message);
} finally {
button.disabled = !window.CURRENT_ROLL_REQUEST;
button.textContent = 'Download PDF';
}
});
// =====================================================
// LOAD OPTIMIZER CORE
// =====================================================