Refactor transport calculation into shared domain
This commit is contained in:
@@ -21,6 +21,7 @@ Backend:
|
||||
- `/api/user` returns the authenticated user.
|
||||
- `/api/calculations/roll` executes a `CalculationRequest` through the deterministic core.
|
||||
- `/api/calculations/roll/modify` applies controlled changes to `CalculationState` and recalculates.
|
||||
- `/api/calculations/transport` executes the shared transport-capacity service.
|
||||
- `/api/reports/roll-calculation.pdf` recalculates a request and renders its authoritative result.
|
||||
- `/api/conversations` creates a process-local conversational session.
|
||||
- `/api/conversations/<id>/messages` runs constrained NLU followed by the existing deterministic calculation functions.
|
||||
@@ -40,7 +41,8 @@ Frontend:
|
||||
|
||||
Domain service:
|
||||
|
||||
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `calculate_roll()`, and `modify_calculation()`.
|
||||
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `get_article()`, `calculate_roll()`, and `modify_calculation()`.
|
||||
- `transport_calculation.py` owns `analyze_transport()` and the canonical active transport presets. The template calls the protected transport API and retains only UI rendering and SVG drawing.
|
||||
- Direct diameter ranges, optional weight, and direct-flow forklift warnings originate there.
|
||||
- Request parsing rejects calculated diameter, weight, warning, and calculation fields.
|
||||
- Natural-language clients may describe or modify a calculation request, but engineering results are generated exclusively by the deterministic Roll Calculator core.
|
||||
@@ -246,14 +248,12 @@ That file includes localized messages and additional equipment requirements. Bef
|
||||
|
||||
## Load Optimizer
|
||||
|
||||
The Load Optimizer is implemented inline in `templates/roll_calculator.html`.
|
||||
`transport_calculation.py` is authoritative for transport presets and capacity
|
||||
analysis through `analyze_transport()`. The active template calls the protected
|
||||
transport endpoint and only collects inputs, renders returned values, and draws
|
||||
the SVG front view.
|
||||
|
||||
Classes:
|
||||
|
||||
- `LoadOptimizer`
|
||||
- `LoadOptimizerUI`
|
||||
|
||||
It uses calculated roll data from the Direct Calculation tab:
|
||||
It uses displayed roll data from the Direct Calculation tab:
|
||||
|
||||
- Roll diameter.
|
||||
- Core diameter.
|
||||
|
||||
@@ -56,6 +56,7 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
|
||||
├── ollama_nlu.py
|
||||
├── pdf_report.py
|
||||
├── roll_calculation.py
|
||||
├── transport_calculation.py
|
||||
├── rollcalc_chat.py
|
||||
├── integrations/
|
||||
│ └── openwebui/
|
||||
@@ -105,6 +106,7 @@ Implemented routes:
|
||||
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
|
||||
| `/api/calculations/roll` | `POST` | Basic Auth | Resolves, validates, and executes a direct roll-diameter request. |
|
||||
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. |
|
||||
| `/api/calculations/transport` | `POST` | Basic Auth | Executes the shared transport-capacity analysis. |
|
||||
| `/api/reports/roll-calculation.pdf` | `POST` | Basic Auth | Recalculates a request authoritatively and returns a one-page PDF. |
|
||||
| `/api/conversations` | `POST` | Basic Auth | Creates an in-memory conversation session. |
|
||||
| `/api/conversations/<id>/messages` | `POST` | Basic Auth | Interprets one utterance and executes the deterministic workflow. |
|
||||
@@ -409,12 +411,12 @@ There is also a richer `static/config.json` with localized messages and more det
|
||||
|
||||
### Load Optimizer
|
||||
|
||||
The Load Optimizer is implemented by inline classes in `templates/roll_calculator.html`:
|
||||
`transport_calculation.py` is the authoritative capacity calculation. It owns
|
||||
the active transport presets and `analyze_transport()`. The browser collects
|
||||
inputs, calls `POST /api/calculations/transport`, renders the returned analysis,
|
||||
and draws the SVG front view; it does not calculate capacity itself.
|
||||
|
||||
- `LoadOptimizer`
|
||||
- `LoadOptimizerUI`
|
||||
|
||||
It uses calculated roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions.
|
||||
It uses the displayed roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions. In particular, the direct tab writes the roll weight with one decimal place before the optimizer consumes it; this behavior is intentionally preserved.
|
||||
|
||||
Preset transport units include:
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from pdf_report import (
|
||||
safe_report_filename,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth()
|
||||
@@ -155,7 +156,11 @@ def log_access(username, endpoint, method, status=200):
|
||||
def index():
|
||||
"""Main calculator page - requires authentication"""
|
||||
log_access(auth.current_user(), "/", "GET")
|
||||
return render_template("roll_calculator.html", core_presets=CORE_PRESETS)
|
||||
return render_template(
|
||||
"roll_calculator.html",
|
||||
core_presets=CORE_PRESETS,
|
||||
transport_presets=transport_presets(),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/static/<path:filename>", methods=["GET"])
|
||||
@@ -212,6 +217,15 @@ def modify_roll_calculation():
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/transport", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_transport_calculation():
|
||||
"""Execute the shared transport-capacity calculation."""
|
||||
result = analyze_transport(request.get_json(silent=True))
|
||||
log_access(auth.current_user(), "/api/calculations/transport", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
def _create_pdf(calculation_request):
|
||||
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
|
||||
report = report_from_calculation_result(result)
|
||||
|
||||
@@ -301,6 +301,28 @@ class ArticleRepository:
|
||||
}
|
||||
|
||||
|
||||
def get_article(
|
||||
article_number: str | None = None,
|
||||
article_name_hint: str | None = None,
|
||||
*,
|
||||
article_repository: ArticleRepository | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve one article through the canonical article repository."""
|
||||
for field, value in (
|
||||
("article_number", article_number),
|
||||
("article_name_hint", article_name_hint),
|
||||
):
|
||||
if value is not None and not isinstance(value, str):
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": field, "message": f"{field} must be text"}],
|
||||
}
|
||||
article_number = article_number.strip() if article_number else None
|
||||
article_name_hint = article_name_hint.strip() if article_name_hint else None
|
||||
repository = article_repository or ArticleRepository.load()
|
||||
return repository.resolve(article_number, article_name_hint)
|
||||
|
||||
|
||||
def _canonical_name(value: str) -> str:
|
||||
value = value.casefold().replace("×", " x ")
|
||||
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
|
||||
|
||||
+73
-118
@@ -769,11 +769,9 @@
|
||||
<div class="lo-presets-section">
|
||||
<h4>🚚 Preset Transport Type:</h4>
|
||||
<div class="lo-preset-buttons">
|
||||
<button class="lo-preset-btn" data-preset="container_20ft">20ft Container</button>
|
||||
<button class="lo-preset-btn" data-preset="container_40ft">40ft Container</button>
|
||||
<button class="lo-preset-btn" data-preset="container_40ft_hc">40ft High Cube</button>
|
||||
<button class="lo-preset-btn" data-preset="lkw_sattelzug">LKW Sattelzug</button>
|
||||
<button class="lo-preset-btn" data-preset="lkw_tandem">LKW Tandem</button>
|
||||
{% for preset in transport_presets %}
|
||||
<button class="lo-preset-btn" data-preset="{{ preset.key }}" data-length="{{ preset.length_m }}" data-width="{{ preset.width_m }}" data-height="{{ preset.height_m }}" data-max-weight="{{ preset.max_weight_kg }}">{{ preset.name }}</button>
|
||||
{% endfor %}
|
||||
<button class="lo-preset-btn" data-preset="custom">Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1360,83 +1358,11 @@ document.getElementById('d-pdf').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// LOAD OPTIMIZER CORE
|
||||
// =====================================================
|
||||
class LoadOptimizer {
|
||||
constructor() {
|
||||
this.state = {
|
||||
rollDiameter: 0, coreDiameter: 150, rollWidth: 0,
|
||||
rollWeight: 0, m2PerRoll: 0, productLength: 0
|
||||
};
|
||||
this.presets = {
|
||||
container_20ft: { name:"20ft Container", length:5.90, width:2.35, height:2.39, maxWeight:21600 },
|
||||
container_40ft: { name:"40ft Container", length:12.19, width:2.35, height:2.39, maxWeight:30480 },
|
||||
container_40ft_hc:{ name:"40ft High Cube", length:12.19, width:2.35, height:2.69, maxWeight:30480 },
|
||||
lkw_sattelzug: { name:"LKW Sattelzug", length:13.50, width:2.50, height:2.70, maxWeight:24000 },
|
||||
lkw_tandem: { name:"LKW Tandem", length:13.50, width:2.50, height:2.70, maxWeight:25000 }
|
||||
};
|
||||
}
|
||||
setRollData(data) {
|
||||
this.state = {
|
||||
rollDiameter: data.rollDiameter || 0,
|
||||
coreDiameter: data.coreDiameter || 150,
|
||||
rollWidth: data.rollWidth || 0,
|
||||
rollWeight: data.rollWeight || 0,
|
||||
m2PerRoll: data.m2PerRoll || 0,
|
||||
productLength: data.productLength || 0
|
||||
};
|
||||
}
|
||||
calculateLoadUtilization(dim, maxLoadWeight, marginSide, marginCeiling) {
|
||||
marginSide = marginSide || 0;
|
||||
marginCeiling = marginCeiling || 0;
|
||||
|
||||
const { rollDiameter, coreDiameter, rollWidth, rollWeight, m2PerRoll } = this.state;
|
||||
const D = rollDiameter / 1000, d = coreDiameter / 1000;
|
||||
|
||||
const effWidth = dim.width - marginSide;
|
||||
const effHeight = dim.height - marginCeiling;
|
||||
|
||||
const rollsAlongLength = Math.floor(dim.length / rollWidth);
|
||||
const rollsAlongWidth = Math.floor(effWidth / D);
|
||||
const rollsPerLayer = rollsAlongLength * rollsAlongWidth;
|
||||
const maxStackHeight = Math.floor(effHeight / D);
|
||||
const maxByGeometry = rollsPerLayer * maxStackHeight;
|
||||
const maxByWeight = rollWeight > 0 ? Math.floor(maxLoadWeight / rollWeight) : Infinity;
|
||||
|
||||
const volPerRoll = (Math.PI / 4) * (D*D - d*d) * rollWidth;
|
||||
const containerVol = dim.length * dim.width * dim.height;
|
||||
const effVol = dim.length * effWidth * effHeight;
|
||||
const maxByVolume = volPerRoll > 0 ? Math.floor(effVol / volPerRoll) : Infinity;
|
||||
|
||||
const scenarios = [
|
||||
{ name:'Weight', rolls: maxByWeight, weight: maxByWeight * rollWeight, type:'weight' },
|
||||
{ name:'Volume', rolls: maxByVolume, weight: maxByVolume * rollWeight, type:'volume' },
|
||||
{ name:'Geometry', rolls: maxByGeometry, weight: maxByGeometry * rollWeight, type:'geometry' }
|
||||
];
|
||||
const limiting = scenarios.reduce((mn, c) => c.rolls < mn.rolls ? c : mn);
|
||||
const finalRolls = limiting.rolls === Infinity ? 0 : limiting.rolls;
|
||||
const finalWeight = finalRolls * rollWeight;
|
||||
const finalM2 = finalRolls * m2PerRoll;
|
||||
|
||||
return {
|
||||
scenarios, limiting, finalRolls, finalWeight, finalM2,
|
||||
utilizationWeight: maxLoadWeight > 0 ? (finalWeight / maxLoadWeight) * 100 : 0,
|
||||
utilizationVolume: containerVol > 0 ? (finalRolls * volPerRoll / containerVol) * 100 : 0,
|
||||
rollsPerLayer, rollsAlongLength, rollsAlongWidth, maxStackHeight,
|
||||
containerVol, volPerRoll, effWidth, effHeight,
|
||||
marginSide, marginCeiling
|
||||
};
|
||||
}
|
||||
fmt(n, dec=1) { return isFinite(n) ? n.toFixed(dec) : '–'; }
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// LOAD OPTIMIZER UI
|
||||
// =====================================================
|
||||
class LoadOptimizerUI {
|
||||
constructor() {
|
||||
this.optimizer = new LoadOptimizer();
|
||||
this.currentPreset = null;
|
||||
this.init();
|
||||
}
|
||||
@@ -1487,12 +1413,12 @@ class LoadOptimizerUI {
|
||||
}
|
||||
loadPreset(id) {
|
||||
if (id === 'custom') { this.clearPresets(); return; }
|
||||
const p = this.optimizer.presets[id];
|
||||
if (!p) return;
|
||||
document.getElementById('customLength').value = p.length;
|
||||
document.getElementById('customWidth').value = p.width;
|
||||
document.getElementById('customHeight').value = p.height;
|
||||
document.getElementById('customMaxWeight').value = p.maxWeight;
|
||||
const button = document.querySelector('[data-preset="'+id+'"]');
|
||||
if (!button) return;
|
||||
document.getElementById('customLength').value = button.dataset.length;
|
||||
document.getElementById('customWidth').value = button.dataset.width;
|
||||
document.getElementById('customHeight').value = button.dataset.height;
|
||||
document.getElementById('customMaxWeight').value = button.dataset.maxWeight;
|
||||
this.updatePresetBtns(id);
|
||||
this.currentPreset = id;
|
||||
this.updateEffectiveHints();
|
||||
@@ -1509,7 +1435,7 @@ class LoadOptimizerUI {
|
||||
return { sideM, ceilM };
|
||||
}
|
||||
|
||||
calculate() {
|
||||
async calculate() {
|
||||
const L = parseFloat(document.getElementById('customLength').value);
|
||||
const W = parseFloat(document.getElementById('customWidth').value);
|
||||
const H = parseFloat(document.getElementById('customHeight').value);
|
||||
@@ -1524,10 +1450,37 @@ class LoadOptimizerUI {
|
||||
return;
|
||||
}
|
||||
const { sideM, ceilM } = this.getMargins();
|
||||
this.optimizer.setRollData(rollData);
|
||||
const res = this.optimizer.calculateLoadUtilization({ length:L, width:W, height:H }, MW, sideM, ceilM);
|
||||
this.showResults(res, MW);
|
||||
this.drawSideView(res, { length:L, width:W, height:H });
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('/api/calculations/transport', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transport_preset: this.currentPreset,
|
||||
length_m: L,
|
||||
width_m: W,
|
||||
height_m: H,
|
||||
max_weight_kg: MW,
|
||||
margin_side_m: sideM,
|
||||
margin_ceiling_m: ceilM,
|
||||
roll_diameter_mm: rollData.rollDiameter,
|
||||
core_diameter_mm: rollData.coreDiameter,
|
||||
roll_width_m: rollData.rollWidth,
|
||||
roll_weight_kg: rollData.rollWeight,
|
||||
product_length_m: rollData.productLength
|
||||
})
|
||||
});
|
||||
} catch (_error) {
|
||||
alert('Transport calculation service failed');
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.status !== 'success') {
|
||||
alert(payload?.invalid?.[0]?.message || 'Transport calculation failed');
|
||||
return;
|
||||
}
|
||||
this.showResults(payload.analysis);
|
||||
this.drawSideView(payload.analysis);
|
||||
}
|
||||
|
||||
getRollData() {
|
||||
@@ -1537,30 +1490,29 @@ class LoadOptimizerUI {
|
||||
const L = parseFloat(document.getElementById('d-le')?.value || 0);
|
||||
const d = parseFloat(document.getElementById('d-co')?.value || 150);
|
||||
if (!D || !W || !GW) return null;
|
||||
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, m2PerRoll:L*W, productLength:L };
|
||||
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, productLength:L };
|
||||
}
|
||||
|
||||
showResults(res, maxW) {
|
||||
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
|
||||
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
|
||||
document.getElementById('totalRollsValue').textContent = res.finalRolls;
|
||||
showResults(analysis) {
|
||||
document.getElementById('limitingFactorValue').textContent = analysis.limiting.name;
|
||||
document.getElementById('totalRollsValue').textContent = analysis.final_rolls;
|
||||
document.getElementById('rollsDetailValue').textContent =
|
||||
'(' + res.rollsAlongLength + ' along length × ' + res.rollsAlongWidth + ' across, ' + res.maxStackHeight + ' layers)';
|
||||
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
|
||||
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
|
||||
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
|
||||
'(' + analysis.layout.rolls_along_length + ' along length × ' + analysis.layout.rolls_along_width + ' across, ' + analysis.layout.max_stack_height + ' layers)';
|
||||
document.getElementById('weightUtilValue').textContent = this.fmt(analysis.utilization_weight);
|
||||
document.getElementById('volumeUtilValue').textContent = this.fmt(analysis.utilization_volume);
|
||||
document.getElementById('productM2Value').textContent = this.fmt(analysis.final_m2, 0);
|
||||
const tbody = document.querySelector('#comparisonTable tbody');
|
||||
tbody.innerHTML = '';
|
||||
res.scenarios.forEach(sc => {
|
||||
analysis.scenarios.forEach(sc => {
|
||||
const tr = document.createElement('tr');
|
||||
const lim = (sc.type === res.limiting.type);
|
||||
const lim = (sc.type === analysis.limiting.type);
|
||||
if (lim) tr.classList.add('lo-recommended');
|
||||
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
|
||||
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
|
||||
const util = this.fmt(sc.utilization_weight);
|
||||
const scM2 = sc.m2 === null ? '–' : Math.round(sc.m2);
|
||||
tr.innerHTML =
|
||||
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
|
||||
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
|
||||
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
|
||||
'<td>'+ (sc.rolls === null ? '–' : sc.rolls) +'</td>'+
|
||||
'<td>'+ (sc.weight === null ? '–' : Math.round(sc.weight)) +'</td>'+
|
||||
'<td>'+ scM2 +'</td>'+
|
||||
'<td>'+ util +'%</td>';
|
||||
tbody.appendChild(tr);
|
||||
@@ -1568,26 +1520,29 @@ class LoadOptimizerUI {
|
||||
document.getElementById('resultsContainer').classList.add('show');
|
||||
}
|
||||
|
||||
drawSideView(res, dim) {
|
||||
fmt(n, dec=1) { return Number.isFinite(n) ? n.toFixed(dec) : '–'; }
|
||||
|
||||
drawSideView(analysis) {
|
||||
const svg = document.getElementById('sideViewSVG');
|
||||
const section = document.getElementById('sideViewSection');
|
||||
if (!svg || !res || res.finalRolls === 0) return;
|
||||
if (!svg || !analysis || analysis.final_rolls === 0) return;
|
||||
|
||||
const D_m = this.optimizer.state.rollDiameter / 1000;
|
||||
const aW = res.rollsAlongWidth;
|
||||
const aL = res.rollsAlongLength;
|
||||
const layers = res.maxStackHeight;
|
||||
const total = res.finalRolls;
|
||||
const marginSide = res.marginSide || 0;
|
||||
const marginCeil = res.marginCeiling || 0;
|
||||
const D_m = analysis.roll.diameter_mm / 1000;
|
||||
const aW = analysis.layout.rolls_along_width;
|
||||
const aL = analysis.layout.rolls_along_length;
|
||||
const layers = analysis.layout.max_stack_height;
|
||||
const total = analysis.final_rolls;
|
||||
const marginSide = analysis.layout.margin_side_m || 0;
|
||||
const marginCeil = analysis.layout.margin_ceiling_m || 0;
|
||||
const dim = analysis.transport;
|
||||
|
||||
const PAD = 52, W_SVG = 800, H_SVG = 300;
|
||||
const scaleX = (W_SVG - 2*PAD) / dim.width;
|
||||
const scaleY = (H_SVG - 2*PAD) / dim.height;
|
||||
const scaleX = (W_SVG - 2*PAD) / dim.width_m;
|
||||
const scaleY = (H_SVG - 2*PAD) / dim.height_m;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const cW = dim.width * scale;
|
||||
const cH = dim.height * scale;
|
||||
const cW = dim.width_m * scale;
|
||||
const cH = dim.height_m * scale;
|
||||
const cX = PAD + ((W_SVG - 2*PAD) - cW) / 2;
|
||||
const cY = PAD + ((H_SVG - 2*PAD) - cH) / 2;
|
||||
const rD = D_m * scale;
|
||||
@@ -1638,12 +1593,12 @@ class LoadOptimizerUI {
|
||||
|
||||
const dimY = cY + cH + 20;
|
||||
s += '<line x1="'+cX.toFixed(1)+'" y1="'+dimY.toFixed(1)+'" x2="'+(cX+cW).toFixed(1)+'" y2="'+dimY.toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width+' m</text>';
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width_m+' m</text>';
|
||||
|
||||
const dimX = cX - 22;
|
||||
const midY = (cY + cH/2).toFixed(1);
|
||||
s += '<line x1="'+dimX.toFixed(1)+'" y1="'+cY.toFixed(1)+'" x2="'+dimX.toFixed(1)+'" y2="'+(cY+cH).toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
|
||||
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height+' m</text>';
|
||||
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height_m+' m</text>';
|
||||
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(cY-8).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#666" font-style="italic">'+aL+' rows deep (into page)</text>';
|
||||
s += '<text x="'+(cX+cW-4).toFixed(1)+'" y="'+(cY+15).toFixed(1)+'" text-anchor="end" font-size="10" fill="#555" font-style="italic">'+aW+' wide × '+layers+' high × '+aL+' deep · '+total+' rolls</text>';
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as rollcalc_app
|
||||
|
||||
@@ -62,6 +63,42 @@ class CalculationApiTests(unittest.TestCase):
|
||||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0)
|
||||
|
||||
def test_transport_endpoint_delegates_to_shared_domain_service(self):
|
||||
payload = {
|
||||
"length_m": 10,
|
||||
"width_m": 5,
|
||||
"height_m": 5,
|
||||
"max_weight_kg": 10000,
|
||||
"roll_diameter_mm": 1000,
|
||||
"core_diameter_mm": 150,
|
||||
"roll_width_m": 1,
|
||||
"roll_weight_kg": 1000,
|
||||
}
|
||||
with patch.object(
|
||||
rollcalc_app,
|
||||
"analyze_transport",
|
||||
return_value={"status": "success", "analysis": {"final_rolls": 10}},
|
||||
) as analyze:
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["analysis"]["final_rolls"], 10)
|
||||
analyze.assert_called_once_with(payload)
|
||||
|
||||
def test_transport_endpoint_returns_structured_domain_validation(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json={"length_m": 0},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["status"], "invalid_parameter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,7 @@ from roll_calculation import (
|
||||
CalculationState,
|
||||
_canonical_name,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
modify_calculation,
|
||||
)
|
||||
|
||||
@@ -90,6 +91,35 @@ class RollCalculationTests(unittest.TestCase):
|
||||
def calculate_fixture(self, payload):
|
||||
return calculate_roll(payload, article_repository=self.repository)
|
||||
|
||||
def test_public_get_article_uses_repository_resolution_and_projection(self):
|
||||
result = get_article("00123", article_repository=self.repository)
|
||||
|
||||
self.assertEqual(result["status"], "resolved")
|
||||
self.assertEqual(result["article"]["number"], "00123")
|
||||
self.assertEqual(result["article"]["width_m"], 4.9)
|
||||
self.assertEqual(result["article"]["area_weight_g_m2"], 1000.0)
|
||||
|
||||
def test_public_get_article_preserves_not_found_ambiguous_and_conflict_results(self):
|
||||
self.assertEqual(
|
||||
get_article("does-not-exist", article_repository=self.repository)["status"],
|
||||
"article_not_found",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
article_name_hint="Duplicated Product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_ambiguous",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
"00123",
|
||||
"Other product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_conflict",
|
||||
)
|
||||
|
||||
def test_headless_matches_representative_existing_ui_values(self):
|
||||
result = calculate_roll(representative_request())
|
||||
calculation = result["calculation"]
|
||||
|
||||
@@ -39,6 +39,12 @@ class TemplatePdfIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("const warning = result.warnings?.[0]", self.template)
|
||||
self.assertIn("const note = result.notes?.[0]", self.template)
|
||||
|
||||
def test_transport_ui_uses_shared_api_without_a_browser_capacity_calculator(self):
|
||||
self.assertIn("fetch('/api/calculations/transport'", self.template)
|
||||
self.assertIn("this.showResults(payload.analysis)", self.template)
|
||||
self.assertNotIn("calculateLoadUtilization", self.template)
|
||||
self.assertNotIn("class LoadOptimizer {", self.template)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
def request(**changes):
|
||||
payload = {
|
||||
"length_m": 10.0,
|
||||
"width_m": 5.0,
|
||||
"height_m": 5.0,
|
||||
"max_weight_kg": 10_000.0,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.0,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 1.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 10.0,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class TransportCalculationTests(unittest.TestCase):
|
||||
def analysis(self, **changes):
|
||||
result = analyze_transport(request(**changes))
|
||||
self.assertEqual(result["status"], "success")
|
||||
return result["analysis"]
|
||||
|
||||
def test_active_presets_match_golden_browser_results(self):
|
||||
expected = {
|
||||
"container_20ft": (8, "Geometry", [21, 19, 8], (2, 2, 2)),
|
||||
"container_40ft": (24, "Geometry", [30, 40, 24], (6, 2, 2)),
|
||||
"container_40ft_hc": (24, "Geometry", [30, 46, 24], (6, 2, 2)),
|
||||
"lkw_sattelzug": (24, "Weight", [24, 54, 24], (6, 2, 2)),
|
||||
"lkw_tandem": (24, "Geometry", [25, 54, 24], (6, 2, 2)),
|
||||
}
|
||||
for preset in transport_presets():
|
||||
with self.subTest(preset=preset.key):
|
||||
result = analyze_transport({
|
||||
"transport_preset": preset.key,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.2,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 2.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 50.0,
|
||||
})
|
||||
analysis = result["analysis"]
|
||||
rolls, limiting, scenarios, layout = expected[preset.key]
|
||||
self.assertEqual(analysis["final_rolls"], rolls)
|
||||
self.assertEqual(analysis["limiting"]["name"], limiting)
|
||||
self.assertEqual(
|
||||
[scenario["rolls"] for scenario in analysis["scenarios"]],
|
||||
scenarios,
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
analysis["layout"]["rolls_along_length"],
|
||||
analysis["layout"]["rolls_along_width"],
|
||||
analysis["layout"]["max_stack_height"],
|
||||
),
|
||||
layout,
|
||||
)
|
||||
|
||||
def test_custom_weight_governing_case_matches_browser_semantics(self):
|
||||
analysis = self.analysis()
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
self.assertEqual(analysis["final_rolls"], 10)
|
||||
self.assertEqual(analysis["remaining_payload_kg"], 0.0)
|
||||
self.assertEqual(analysis["final_m2"], 100.0)
|
||||
|
||||
def test_geometry_governing_and_impossible_geometry_are_preserved(self):
|
||||
geometry = self.analysis(max_weight_kg=1_000_000.0)
|
||||
self.assertEqual(geometry["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(geometry["final_rolls"], 250)
|
||||
|
||||
impossible = self.analysis(
|
||||
length_m=2.0,
|
||||
width_m=2.0,
|
||||
height_m=2.0,
|
||||
roll_diameter_mm=3000.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(impossible["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(impossible["final_rolls"], 0)
|
||||
self.assertEqual(impossible["layout"]["rolls_along_width"], 0)
|
||||
|
||||
def test_volume_governing_case_preserves_unvalidated_browser_sign_behavior(self):
|
||||
# Browser getRollData() rejects zero but not a negative diameter. This
|
||||
# non-physical case characterizes its otherwise reachable volume limit.
|
||||
analysis = self.analysis(
|
||||
roll_diameter_mm=-2400.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(analysis["limiting"]["name"], "Volume")
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [100, 55, 90])
|
||||
|
||||
def test_weight_volume_geometry_tie_order_is_weight_volume_geometry(self):
|
||||
analysis = self.analysis(max_weight_kg=250_000.0)
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [250, 325, 250])
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
|
||||
def test_floor_and_clearance_behavior_matches_browser_math_floor(self):
|
||||
below_boundary = self.analysis(max_weight_kg=999.9)
|
||||
at_boundary = self.analysis(max_weight_kg=1000.0)
|
||||
self.assertEqual(below_boundary["final_rolls"], 0)
|
||||
self.assertEqual(at_boundary["final_rolls"], 1)
|
||||
|
||||
clearance = self.analysis(margin_side_m=0.5, margin_ceiling_m=1.0)
|
||||
self.assertEqual(clearance["layout"]["effective_width_m"], 4.5)
|
||||
self.assertEqual(clearance["layout"]["effective_height_m"], 4.0)
|
||||
self.assertEqual(clearance["layout"]["rolls_along_width"], 4)
|
||||
self.assertEqual(clearance["layout"]["max_stack_height"], 4)
|
||||
|
||||
def test_displayed_one_decimal_roll_weight_is_consumed_without_recalculation(self):
|
||||
# The direct UI writes d-wo using toFixed(1), then transport reads it.
|
||||
displayed = self.analysis(roll_weight_kg=1000.0)
|
||||
unrounded = self.analysis(roll_weight_kg=1000.04)
|
||||
self.assertEqual(displayed["final_rolls"], 10)
|
||||
self.assertEqual(unrounded["final_rolls"], 9)
|
||||
|
||||
def test_existing_ui_validation_boundary_is_structured_for_the_api(self):
|
||||
for field, value in (("length_m", 0), ("width_m", -1), ("max_weight_kg", 0)):
|
||||
with self.subTest(field=field):
|
||||
result = analyze_transport(request(**{field: value}))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], field)
|
||||
|
||||
result = analyze_transport(request(roll_weight_kg=0))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], "roll_weight_kg")
|
||||
|
||||
def test_missing_product_length_preserves_browser_zero_square_meter_behavior(self):
|
||||
payload = request()
|
||||
del payload["product_length_m"]
|
||||
result = analyze_transport(payload)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["analysis"]["final_m2"], 0.0)
|
||||
|
||||
def test_volume_and_utilization_values_are_golden(self):
|
||||
analysis = self.analysis()
|
||||
self.assertAlmostEqual(analysis["volume_per_roll_m3"], 0.7677267047210057)
|
||||
self.assertAlmostEqual(analysis["utilization_weight"], 100.0)
|
||||
self.assertAlmostEqual(analysis["utilization_volume"], 3.0709068188840227)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Shared transport-capacity calculation extracted from the active UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPreset:
|
||||
key: str
|
||||
name: str
|
||||
length_m: float
|
||||
width_m: float
|
||||
height_m: float
|
||||
max_weight_kg: float
|
||||
|
||||
|
||||
# Preserve the active browser order and values exactly.
|
||||
TRANSPORT_PRESETS = (
|
||||
TransportPreset("container_20ft", "20ft Container", 5.90, 2.35, 2.39, 21600),
|
||||
TransportPreset("container_40ft", "40ft Container", 12.19, 2.35, 2.39, 30480),
|
||||
TransportPreset("container_40ft_hc", "40ft High Cube", 12.19, 2.35, 2.69, 30480),
|
||||
TransportPreset("lkw_sattelzug", "LKW Sattelzug", 13.50, 2.50, 2.70, 24000),
|
||||
TransportPreset("lkw_tandem", "LKW Tandem", 13.50, 2.50, 2.70, 25000),
|
||||
)
|
||||
PRESETS_BY_KEY = {preset.key: preset for preset in TRANSPORT_PRESETS}
|
||||
|
||||
|
||||
def transport_presets() -> tuple[TransportPreset, ...]:
|
||||
"""Return the canonical active transport presets."""
|
||||
return TRANSPORT_PRESETS
|
||||
|
||||
|
||||
def _invalid(message: str, field: str = "request") -> dict[str, Any]:
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": field, "message": message}],
|
||||
}
|
||||
|
||||
|
||||
def _number(payload: dict[str, Any], field: str) -> float | None:
|
||||
value = payload.get(field)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field} must be a number")
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"{field} must be finite")
|
||||
return value
|
||||
|
||||
|
||||
def _json_number(value: float) -> float | None:
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def analyze_transport(payload: Any) -> dict[str, Any]:
|
||||
"""Calculate transport capacity using the active LoadOptimizer semantics.
|
||||
|
||||
Roll weight is deliberately consumed as supplied. The browser supplies the
|
||||
one-decimal displayed weight from the direct-calculation tab, which is an
|
||||
observable part of the legacy transport behavior.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return _invalid("request must be a JSON object")
|
||||
|
||||
allowed = {
|
||||
"transport_preset",
|
||||
"length_m",
|
||||
"width_m",
|
||||
"height_m",
|
||||
"max_weight_kg",
|
||||
"margin_side_m",
|
||||
"margin_ceiling_m",
|
||||
"roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"roll_width_m",
|
||||
"roll_weight_kg",
|
||||
"product_length_m",
|
||||
}
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
return _invalid("unknown request fields: " + ", ".join(unknown))
|
||||
|
||||
try:
|
||||
preset_key = payload.get("transport_preset")
|
||||
if preset_key is not None and not isinstance(preset_key, str):
|
||||
raise ValueError("transport_preset must be text")
|
||||
preset = PRESETS_BY_KEY.get(preset_key) if preset_key else None
|
||||
if preset_key and preset is None:
|
||||
return _invalid("unknown transport preset", "transport_preset")
|
||||
|
||||
length = _number(payload, "length_m")
|
||||
width = _number(payload, "width_m")
|
||||
height = _number(payload, "height_m")
|
||||
max_weight = _number(payload, "max_weight_kg")
|
||||
if preset and all(value is None for value in (length, width, height, max_weight)):
|
||||
length = preset.length_m
|
||||
width = preset.width_m
|
||||
height = preset.height_m
|
||||
max_weight = preset.max_weight_kg
|
||||
|
||||
margin_side = _number(payload, "margin_side_m")
|
||||
margin_ceiling = _number(payload, "margin_ceiling_m")
|
||||
margin_side = 0.0 if margin_side is None else margin_side
|
||||
margin_ceiling = 0.0 if margin_ceiling is None else margin_ceiling
|
||||
|
||||
roll_diameter = _number(payload, "roll_diameter_mm")
|
||||
core_diameter = _number(payload, "core_diameter_mm")
|
||||
roll_width = _number(payload, "roll_width_m")
|
||||
roll_weight = _number(payload, "roll_weight_kg")
|
||||
product_length = _number(payload, "product_length_m")
|
||||
product_length = 0.0 if product_length is None else product_length
|
||||
except ValueError as error:
|
||||
return _invalid(str(error))
|
||||
|
||||
for field, value in (
|
||||
("length_m", length),
|
||||
("width_m", width),
|
||||
("height_m", height),
|
||||
("max_weight_kg", max_weight),
|
||||
):
|
||||
if value is None or value <= 0:
|
||||
return _invalid("must be greater than zero", field)
|
||||
|
||||
# The browser's getRollData() rejects falsy values but does not otherwise
|
||||
# validate roll inputs. Preserve that boundary rather than improve it here.
|
||||
for field, value in (
|
||||
("roll_diameter_mm", roll_diameter),
|
||||
("roll_width_m", roll_width),
|
||||
("roll_weight_kg", roll_weight),
|
||||
):
|
||||
if value is None or value == 0:
|
||||
return _invalid("required", field)
|
||||
if core_diameter is None:
|
||||
return _invalid("required", "core_diameter_mm")
|
||||
|
||||
diameter_m = roll_diameter / 1000
|
||||
core_m = core_diameter / 1000
|
||||
effective_width = width - margin_side
|
||||
effective_height = height - margin_ceiling
|
||||
|
||||
rolls_along_length = math.floor(length / roll_width)
|
||||
rolls_along_width = math.floor(effective_width / diameter_m)
|
||||
rolls_per_layer = rolls_along_length * rolls_along_width
|
||||
max_stack_height = math.floor(effective_height / diameter_m)
|
||||
max_by_geometry = rolls_per_layer * max_stack_height
|
||||
max_by_weight = math.floor(max_weight / roll_weight) if roll_weight > 0 else math.inf
|
||||
|
||||
volume_per_roll = (math.pi / 4) * (diameter_m**2 - core_m**2) * roll_width
|
||||
container_volume = length * width * height
|
||||
effective_volume = length * effective_width * effective_height
|
||||
max_by_volume = (
|
||||
math.floor(effective_volume / volume_per_roll)
|
||||
if volume_per_roll > 0
|
||||
else math.inf
|
||||
)
|
||||
|
||||
# The list order and strict comparison preserve JavaScript reduce() ties:
|
||||
# Weight wins over Volume, which wins over Geometry.
|
||||
scenarios = [
|
||||
{"name": "Weight", "rolls": max_by_weight, "type": "weight"},
|
||||
{"name": "Volume", "rolls": max_by_volume, "type": "volume"},
|
||||
{"name": "Geometry", "rolls": max_by_geometry, "type": "geometry"},
|
||||
]
|
||||
limiting = scenarios[0]
|
||||
for scenario in scenarios[1:]:
|
||||
if scenario["rolls"] < limiting["rolls"]:
|
||||
limiting = scenario
|
||||
|
||||
final_rolls = 0 if math.isinf(limiting["rolls"]) else limiting["rolls"]
|
||||
m2_per_roll = product_length * roll_width
|
||||
final_weight = final_rolls * roll_weight
|
||||
final_m2 = final_rolls * m2_per_roll
|
||||
for scenario in scenarios:
|
||||
scenario["weight"] = scenario["rolls"] * roll_weight
|
||||
scenario["m2"] = scenario["rolls"] * m2_per_roll
|
||||
scenario["utilization_weight"] = (
|
||||
(scenario["weight"] / max_weight) * 100 if max_weight > 0 else 0
|
||||
)
|
||||
scenario["rolls"] = _json_number(scenario["rolls"])
|
||||
scenario["weight"] = _json_number(scenario["weight"])
|
||||
scenario["m2"] = _json_number(scenario["m2"])
|
||||
|
||||
analysis = {
|
||||
"transport_preset": preset_key or None,
|
||||
"transport": {
|
||||
"length_m": length,
|
||||
"width_m": width,
|
||||
"height_m": height,
|
||||
"max_weight_kg": max_weight,
|
||||
},
|
||||
"roll": {
|
||||
"diameter_mm": roll_diameter,
|
||||
"core_diameter_mm": core_diameter,
|
||||
"width_m": roll_width,
|
||||
"weight_kg": roll_weight,
|
||||
"product_length_m": product_length,
|
||||
},
|
||||
"scenarios": scenarios,
|
||||
"limiting": {
|
||||
"name": limiting["name"],
|
||||
"type": limiting["type"],
|
||||
"rolls": _json_number(limiting["rolls"]),
|
||||
},
|
||||
"final_rolls": final_rolls,
|
||||
"final_weight_kg": final_weight,
|
||||
"final_m2": final_m2,
|
||||
"remaining_payload_kg": max_weight - final_weight,
|
||||
"utilization_weight": (final_weight / max_weight) * 100 if max_weight > 0 else 0,
|
||||
"utilization_volume": (
|
||||
(final_rolls * volume_per_roll / container_volume) * 100
|
||||
if container_volume > 0
|
||||
else 0
|
||||
),
|
||||
"layout": {
|
||||
"rolls_per_layer": rolls_per_layer,
|
||||
"rolls_along_length": rolls_along_length,
|
||||
"rolls_along_width": rolls_along_width,
|
||||
"max_stack_height": max_stack_height,
|
||||
"effective_width_m": effective_width,
|
||||
"effective_height_m": effective_height,
|
||||
"margin_side_m": margin_side,
|
||||
"margin_ceiling_m": margin_ceiling,
|
||||
},
|
||||
"container_volume_m3": container_volume,
|
||||
"effective_volume_m3": effective_volume,
|
||||
"volume_per_roll_m3": volume_per_roll,
|
||||
}
|
||||
return {"status": "success", "analysis": analysis}
|
||||
Reference in New Issue
Block a user