From fecb394272dcdbba19ed19c47b107d492f169a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 25 Feb 2026 15:05:23 +0100 Subject: [PATCH] fix(back-end) calculator improvements --- .../controller/QuoteSessionController.java | 81 ++++++++++++++++--- .../printcalculator/dto/PrintSettingsDto.java | 5 ++ .../com/printcalculator/entity/OrderItem.java | 33 ++++++++ .../printcalculator/model/QuoteResult.java | 9 +-- .../printcalculator/service/OrderService.java | 63 +++++++++++++-- .../service/QuoteCalculator.java | 18 +++-- db.sql | 5 ++ .../calculator/calculator-page.component.ts | 26 +++++- .../quote-result/quote-result.component.html | 3 +- .../upload-form/upload-form.component.ts | 53 ++++++++++-- .../services/quote-estimator.service.ts | 79 +++++------------- .../features/checkout/checkout.component.html | 4 +- frontend/src/assets/i18n/en.json | 3 + frontend/src/assets/i18n/it.json | 14 +++- 14 files changed, 290 insertions(+), 106 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 5202e65..eccf104 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -18,10 +18,12 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.OffsetDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -200,9 +202,8 @@ public class QuoteSessionController { // Store breakdown Map breakdown = new HashMap<>(); - breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation? - // Better: QuoteResult could expose detailed breakdown. For now just storing what we have. - breakdown.put("setup_fee", result.getSetupCost()); + breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level + breakdown.put("setup_fee", 0); item.setPricingBreakdown(breakdown); // Dimensions @@ -210,9 +211,9 @@ public class QuoteSessionController { // If GCodeParser doesn't return size, we might defaults or 0. // Stats has filament used. // Let's set dummy for now or upgrade parser later. - item.setBoundingBoxXMm(BigDecimal.ZERO); - item.setBoundingBoxYMm(BigDecimal.ZERO); - item.setBoundingBoxZMm(BigDecimal.ZERO); + item.setBoundingBoxXMm(settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO); + item.setBoundingBoxYMm(settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO); + item.setBoundingBoxZMm(settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO); item.setCreatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now()); @@ -245,7 +246,7 @@ public class QuoteSessionController { case "standard": default: settings.setLayerHeight(0.20); - settings.setInfillDensity(20.0); + settings.setInfillDensity(15.0); settings.setInfillPattern("grid"); break; } @@ -308,20 +309,78 @@ public class QuoteSessionController { List items = lineItemRepo.findByQuoteSessionId(id); - // Calculate Totals + // Calculate Totals and global session hours BigDecimal itemsTotal = BigDecimal.ZERO; + BigDecimal totalSeconds = BigDecimal.ZERO; + for (QuoteLineItem item : items) { BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity())); itemsTotal = itemsTotal.add(lineTotal); + + if (item.getPrintTimeSeconds() != null) { + totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()))); + } + } + + BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); + + itemsTotal = itemsTotal.add(globalMachineCost); + + // Map items to DTO to embed distributed machine cost + List> itemsDto = new ArrayList<>(); + for (QuoteLineItem item : items) { + Map dto = new HashMap<>(); + dto.put("id", item.getId()); + dto.put("originalFilename", item.getOriginalFilename()); + dto.put("quantity", item.getQuantity()); + dto.put("printTimeSeconds", item.getPrintTimeSeconds()); + dto.put("materialGrams", item.getMaterialGrams()); + dto.put("colorCode", item.getColorCode()); + dto.put("status", item.getStatus()); + + BigDecimal unitPrice = item.getUnitPriceChf(); + if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())); + BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = globalMachineCost.multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP); + unitPrice = unitPrice.add(unitMachineCost); + } + dto.put("unitPriceChf", unitPrice); + itemsDto.add(dto); } BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; - BigDecimal grandTotal = itemsTotal.add(setupFee); + + // Calculate shipping cost based on dimensions + boolean exceedsBaseSize = false; + for (QuoteLineItem item : items) { + BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; + BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; + BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; + + BigDecimal[] dims = {x, y, z}; + java.util.Arrays.sort(dims); + + if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || + dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || + dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { + exceedsBaseSize = true; + break; + } + } + BigDecimal shippingCostChf = exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00); + + BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf); Map response = new HashMap<>(); response.put("session", session); - response.put("items", items); - response.put("itemsTotalChf", itemsTotal); + response.put("items", itemsDto); + response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost + response.put("shippingCostChf", shippingCostChf); + response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now) response.put("grandTotalChf", grandTotal); return ResponseEntity.ok(response); diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java index 75aafc9..41e62c5 100644 --- a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -21,4 +21,9 @@ public class PrintSettingsDto { private String infillPattern; private Boolean supportsEnabled; private String notes; + + // Dimensions + private Double boundingBoxX; + private Double boundingBoxY; + private Double boundingBoxZ; } diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index 287efcc..a71fa5a 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -57,6 +57,15 @@ public class OrderItem { @Column(name = "material_grams", precision = 12, scale = 2) private BigDecimal materialGrams; + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxXMm; + + @Column(name = "bounding_box_y_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxYMm; + + @Column(name = "bounding_box_z_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxZMm; + @Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2) private BigDecimal unitPriceChf; @@ -181,6 +190,30 @@ public class OrderItem { this.materialGrams = materialGrams; } + public BigDecimal getBoundingBoxXMm() { + return boundingBoxXMm; + } + + public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) { + this.boundingBoxXMm = boundingBoxXMm; + } + + public BigDecimal getBoundingBoxYMm() { + return boundingBoxYMm; + } + + public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) { + this.boundingBoxYMm = boundingBoxYMm; + } + + public BigDecimal getBoundingBoxZMm() { + return boundingBoxZMm; + } + + public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) { + this.boundingBoxZMm = boundingBoxZMm; + } + public BigDecimal getUnitPriceChf() { return unitPriceChf; } diff --git a/backend/src/main/java/com/printcalculator/model/QuoteResult.java b/backend/src/main/java/com/printcalculator/model/QuoteResult.java index df155d4..0fee392 100644 --- a/backend/src/main/java/com/printcalculator/model/QuoteResult.java +++ b/backend/src/main/java/com/printcalculator/model/QuoteResult.java @@ -4,13 +4,10 @@ public class QuoteResult { private double totalPrice; private String currency; private PrintStats stats; - private double setupCost; - - public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) { + public QuoteResult(double totalPrice, String currency, PrintStats stats) { this.totalPrice = totalPrice; this.currency = currency; this.stats = stats; - this.setupCost = setupCost; } public double getTotalPrice() { @@ -24,8 +21,4 @@ public class QuoteResult { public PrintStats getStats() { return stats; } - - public double getSetupCost() { - return setupCost; - } } diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index fbf7849..6e325a8 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -8,6 +8,8 @@ import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.service.QuoteCalculator; import com.printcalculator.event.OrderCreatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -37,6 +40,8 @@ public class OrderService { private final QrBillService qrBillService; private final ApplicationEventPublisher eventPublisher; private final PaymentService paymentService; + private final QuoteCalculator quoteCalculator; + private final PricingPolicyRepository pricingRepo; public OrderService(OrderRepository orderRepo, OrderItemRepository orderItemRepo, @@ -47,7 +52,9 @@ public class OrderService { InvoicePdfRenderingService invoiceService, QrBillService qrBillService, ApplicationEventPublisher eventPublisher, - PaymentService paymentService) { + PaymentService paymentService, + QuoteCalculator quoteCalculator, + PricingPolicyRepository pricingRepo) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; @@ -58,6 +65,8 @@ public class OrderService { this.qrBillService = qrBillService; this.eventPublisher = eventPublisher; this.paymentService = paymentService; + this.quoteCalculator = quoteCalculator; + this.pricingRepo = pricingRepo; } @Transactional @@ -145,12 +154,41 @@ public class OrderService { order.setTotalChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO); order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); - order.setShippingCostChf(BigDecimal.valueOf(9.00)); + + // Calculate shipping cost based on dimensions before initial save + boolean exceedsBaseSize = false; + for (QuoteLineItem item : quoteItems) { + BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; + BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; + BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; + + BigDecimal[] dims = {x, y, z}; + java.util.Arrays.sort(dims); + + if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || + dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || + dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { + exceedsBaseSize = true; + break; + } + } + order.setShippingCostChf(exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00)); order = orderRepo.save(order); List savedItems = new ArrayList<>(); + // Calculate global machine cost upfront + BigDecimal totalSeconds = BigDecimal.ZERO; + for (QuoteLineItem qItem : quoteItems) { + if (qItem.getPrintTimeSeconds() != null) { + totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity()))); + } + } + BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); + for (QuoteLineItem qItem : quoteItems) { OrderItem oItem = new OrderItem(); oItem.setOrder(order); @@ -159,10 +197,22 @@ public class OrderService { oItem.setColorCode(qItem.getColorCode()); oItem.setMaterialCode(session.getMaterialCode()); - oItem.setUnitPriceChf(qItem.getUnitPriceChf()); - oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); + BigDecimal distributedUnitPrice = qItem.getUnitPriceChf(); + if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())); + BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = globalMachineCost.multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP); + distributedUnitPrice = distributedUnitPrice.add(unitMachineCost); + } + + oItem.setUnitPriceChf(distributedUnitPrice); + oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity()))); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setMaterialGrams(qItem.getMaterialGrams()); + oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm()); + oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm()); + oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm()); UUID fileUuid = UUID.randomUUID(); String ext = getExtension(qItem.getOriginalFilename()); @@ -196,10 +246,7 @@ public class OrderService { } order.setSubtotalChf(subtotal); - if (order.getShippingCostChf() == null) { - order.setShippingCostChf(BigDecimal.valueOf(9.00)); - } - + BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); order.setTotalChf(total); diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index 432c62f..1ffe379 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -79,25 +79,27 @@ public class QuoteCalculator { BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); - // Machine Cost: Tiered + // We do NOT add tiered machine cost here anymore - it is calculated globally per session BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - BigDecimal machineCost = calculateMachineCost(policy, totalHours); // Energy Cost: (watts / 1000) * hours * costPerKwh BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal kwh = kw.multiply(totalHours); BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh()); - // Subtotal (Costs + Fixed Fees) - BigDecimal fixedFee = policy.getFixedJobFeeChf(); - BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee); + // Subtotal (Costs without Fixed Fees and without Machine Tiers) + BigDecimal subtotal = materialCost.add(energyCost); // Markup // Markup is percentage (e.g. 20.0) BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); - BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); - - return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue()); + subtotal = subtotal.multiply(markupFactor); + return new QuoteResult(subtotal.doubleValue(), "CHF", stats); + } + public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) { + BigDecimal rawCost = calculateMachineCost(policy, hours); + BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); + return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); } private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) { diff --git a/db.sql b/db.sql index 8c4bf80..a55b16a 100644 --- a/db.sql +++ b/db.sql @@ -535,6 +535,11 @@ CREATE TABLE IF NOT EXISTS order_items -- Snapshot output print_time_seconds integer CHECK (print_time_seconds >= 0), material_grams numeric(12, 2) CHECK (material_grams >= 0), + + bounding_box_x_mm numeric(10, 3), + bounding_box_y_mm numeric(10, 3), + bounding_box_z_mm numeric(10, 3), + unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0), line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0), diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index c0e362f..a997649 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -208,9 +208,31 @@ export class CalculatorPageComponent implements OnInit { // 2. Update backend session if ID exists if (event.id) { + const currentSessionId = this.result()?.sessionId; + if (!currentSessionId) return; + + this.loading.set(true); this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({ - next: (res) => console.log('Line item updated', res), - error: (err) => console.error('Failed to update line item', err) + next: () => { + // 3. Fetch the updated session totals from the backend + this.estimator.getQuoteSession(currentSessionId).subscribe({ + next: (sessionData) => { + const newResult = this.estimator.mapSessionToQuoteResult(sessionData); + // Preserve notes + newResult.notes = this.result()?.notes; + this.result.set(newResult); + this.loading.set(false); + }, + error: (err) => { + console.error('Failed to refresh session totals', err); + this.loading.set(false); + } + }); + }, + error: (err) => { + console.error('Failed to update line item', err); + this.loading.set(false); + } }); } } diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 769cb0e..6f06312 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -21,7 +21,8 @@
- {{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }} + {{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}
+ {{ 'CALC.SHIPPING_NOTE' | translate }}
@if (result().notes) { diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index d251d59..aa1a2f2 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -10,11 +10,15 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component'; import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service'; import { getColorHex } from '../../../../core/constants/colors.const'; +import * as THREE from 'three'; +// @ts-ignore +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; interface FormItem { file: File; quantity: number; color: string; + dimensions?: {x: number, y: number, z: number}; } @Component({ @@ -69,6 +73,35 @@ export class UploadFormComponent implements OnInit { return name.endsWith('.stl'); } + private async getStlDimensions(file: File): Promise<{x: number, y: number, z: number} | null> { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const loader = new STLLoader(); + const geometry = loader.parse(e.target?.result as ArrayBuffer); + geometry.computeBoundingBox(); + if (geometry.boundingBox) { + const size = new THREE.Vector3(); + geometry.boundingBox.getSize(size); + resolve({ + x: Math.round(size.x * 10) / 10, + y: Math.round(size.y * 10) / 10, + z: Math.round(size.z * 10) / 10 + }); + return; + } + resolve(null); + } catch (err) { + console.error("Error parsing STL for dimensions:", err); + resolve(null); + } + }; + reader.onerror = () => resolve(null); + reader.readAsArrayBuffer(file); + }); + } + constructor() { this.form = this.fb.group({ itemsTouched: [false], // Hack to track touched state for custom items list @@ -77,7 +110,7 @@ export class UploadFormComponent implements OnInit { items: [[]], // Track items in form for validation if needed notes: [''], // Advanced fields - infillDensity: [20, [Validators.min(0), Validators.max(100)]], + infillDensity: [15, [Validators.min(0), Validators.max(100)]], layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], nozzleDiameter: [0.4, Validators.required], infillPattern: ['grid'], @@ -136,7 +169,7 @@ export class UploadFormComponent implements OnInit { } } - onFilesDropped(newFiles: File[]) { + async onFilesDropped(newFiles: File[]) { const MAX_SIZE = 200 * 1024 * 1024; // 200MB const validItems: FormItem[] = []; let hasError = false; @@ -145,8 +178,13 @@ export class UploadFormComponent implements OnInit { if (file.size > MAX_SIZE) { hasError = true; } else { + let dimensions = undefined; + if (file.name.toLowerCase().endsWith('.stl')) { + const dims = await this.getStlDimensions(file); + if (dims) dimensions = dims; + } // Default color is Black - validItems.push({ file, quantity: 1, color: 'Black' }); + validItems.push({ file, quantity: 1, color: 'Black', dimensions }); } } @@ -238,11 +276,16 @@ export class UploadFormComponent implements OnInit { }); } - setFiles(files: File[]) { + async setFiles(files: File[]) { const validItems: FormItem[] = []; for (const file of files) { + let dimensions = undefined; + if (file.name.toLowerCase().endsWith('.stl')) { + const dims = await this.getStlDimensions(file); + if (dims) dimensions = dims; + } // Default color is Black or derive from somewhere if possible, but here we just init - validItems.push({ file, quantity: 1, color: 'Black' }); + validItems.push({ file, quantity: 1, color: 'Black', dimensions }); } if (validItems.length > 0) { diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 6f9f89e..d55c70a 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; export interface QuoteRequest { - items: { file: File, quantity: number, color?: string }[]; + items: { file: File, quantity: number, color?: string, dimensions?: {x: number, y: number, z: number} }[]; material: string; quality: string; notes?: string; @@ -32,6 +32,7 @@ export interface QuoteResult { sessionId?: string; items: QuoteItem[]; setupCost: number; + globalMachineCost: number; currency: string; totalPrice: number; totalTimeHours: number; @@ -219,6 +220,9 @@ export class QuoteEstimatorService { quality: request.quality, supportsEnabled: request.supportEnabled, color: item.color || '#FFFFFF', + boundingBoxX: item.dimensions?.x, + boundingBoxY: item.dimensions?.y, + boundingBoxZ: item.dimensions?.z, layerHeight: request.mode === 'advanced' ? request.layerHeight : null, infillDensity: request.mode === 'advanced' ? request.infillDensity : null, infillPattern: request.mode === 'advanced' ? request.infillPattern : null, @@ -260,59 +264,19 @@ export class QuoteEstimatorService { }); const finalize = (responses: any[], setupCost: number, sessionId: string) => { - observer.next(100); - const items: QuoteItem[] = []; - let grandTotal = 0; - let totalTime = 0; - let totalWeight = 0; - let validCount = 0; - - responses.forEach((res, idx) => { - if (!res || !res.success) return; - validCount++; - - const unitPrice = res.unitPriceChf || 0; - const quantity = res.originalQty || 1; - - items.push({ - id: res.id, - fileName: res.fileName, - unitPrice: unitPrice, - unitTime: res.printTimeSeconds || 0, - unitWeight: res.materialGrams || 0, - quantity: quantity, - material: request.material, - color: res.originalItem.color || 'Default' - // Store ID if needed for updates? QuoteItem interface might need update - // or we map it in component - }); - - grandTotal += unitPrice * quantity; - totalTime += (res.printTimeSeconds || 0) * quantity; - totalWeight += (res.materialGrams || 0) * quantity; + this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({ + next: (sessionData) => { + observer.next(100); + const result = this.mapSessionToQuoteResult(sessionData); + result.notes = request.notes; + observer.next(result); + observer.complete(); + }, + error: (err) => { + console.error('Failed to fetch final session calculation', err); + observer.error('Failed to calculate final quote'); + } }); - - if (validCount === 0) { - observer.error('All calculations failed.'); - return; - } - - grandTotal += setupCost; - - const result: QuoteResult = { - sessionId: sessionId, - items, - setupCost: setupCost, - currency: 'CHF', - totalPrice: Math.round(grandTotal * 100) / 100, - totalTimeHours: Math.floor(totalTime / 3600), - totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), - totalWeight: Math.ceil(totalWeight), - notes: request.notes - }; - - observer.next(result); - observer.complete(); }; }); } @@ -361,10 +325,11 @@ export class QuoteEstimatorService { // But line items might have different colors. color: item.colorCode })), - setupCost: session.setupCostChf, - currency: 'CHF', // Fixed for now - totalPrice: sessionData.grandTotalChf, - totalTimeHours: Math.floor(totalTime / 3600), + setupCost: session.setupCostChf || 0, + globalMachineCost: sessionData.globalMachineCostChf || 0, + currency: 'CHF', // Fixed for now + totalPrice: (sessionData.itemsTotalChf || 0) + (session.setupCostChf || 0) + (sessionData.shippingCostChf || 0), + totalTimeHours: Math.floor(totalTime / 3600), totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), totalWeight: Math.ceil(totalWeight), notes: session.notes diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 7ae90a7..1f1b3c0 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -143,11 +143,11 @@
{{ 'CHECKOUT.SHIPPING' | translate }} - {{ 9.00 | currency:'CHF' }} + {{ session.shippingCostChf | currency:'CHF' }}
{{ 'CHECKOUT.TOTAL' | translate }} - {{ (session.grandTotalChf + 9.00) | currency:'CHF' }} + {{ session.grandTotalChf | currency:'CHF' }}
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 8bdd593..20e1b6a 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -57,6 +57,7 @@ "CALCULATE": "Calculate Quote", "RESULT": "Estimated Quote", "TIME": "Print Time", + "MACHINE_COST": "Machine Cost", "COST": "Total Cost", "ORDER": "Order Now", "CONSULT": "Request Consultation", @@ -178,6 +179,8 @@ "PROCESSING": "Processing...", "SUMMARY_TITLE": "Order Summary", "SUBTOTAL": "Subtotal", + "ITEMS_BASE_SUBTOTAL": "Items Subtotal", + "MACHINE_COST": "Machine Cost", "SETUP_FEE": "Setup Fee", "TOTAL": "Total", "QTY": "Qty", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index b73895c..4526a0d 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -81,6 +81,7 @@ "CALCULATE": "Calcola Preventivo", "RESULT": "Preventivo Stimato", "TIME": "Tempo Stampa", + "MACHINE_COST": "Costo Macchina", "COST": "Costo Totale", "ORDER": "Ordina Ora", "CONSULT": "Richiedi Consulenza", @@ -102,6 +103,7 @@ "PROCESSING": "Elaborazione...", "NOTES_PLACEHOLDER": "Istruzioni specifiche...", "SETUP_NOTE": "* Include {{cost}} Costo di Setup", + "SHIPPING_NOTE": "** costi di spedizione esclusi calcolati al prossimo passaggio", "STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf" }, "QUOTE": { @@ -167,7 +169,7 @@ }, "LOCATIONS": { "TITLE": "Le Nostre Sedi", - "SUBTITLE": "Siamo presenti in due sedi per coprire meglio il territorio. Seleziona la sede per vedere i dettagli.", + "SUBTITLE": "Siamo presenti in due sedi. Seleziona la sede per vedere i dettagli.", "TICINO": "Ticino", "BIENNE": "Bienne", "ADDRESS_TICINO": "Via G. Pioda 29a, 6710 Biasca (TI)", @@ -244,19 +246,23 @@ "PROCESSING": "Elaborazione...", "SUMMARY_TITLE": "Riepilogo Ordine", "SUBTOTAL": "Subtotale", + "ITEMS_BASE_SUBTOTAL": "Costo Base Articoli", + "MACHINE_COST": "Costo Macchina", "SETUP_FEE": "Costo di Avvio", "TOTAL": "Totale", "QTY": "Qtà", - "SHIPPING": "Spedizione", + "SHIPPING": "Spedizione (CH)", "INVALID_EMAIL": "Email non valida", "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", - "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)" + "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)", + "SHIPPING_CALCULATED_NEXT_STEP": "il costo di spedizione viene calcolato al prossimo passaggio", + "EXCLUDES_SHIPPING": "Escluso costo di spedizione" }, "PAYMENT": { "TITLE": "Pagamento", "METHOD": "Metodo di Pagamento", "TWINT_TITLE": "Paga con TWINT", - "TWINT_DESC": "Inquadra il codice con l'app TWINT", + "TWINT_DESC": "Inquadra il codice con l'app TWINT, da mobile clicca il bottone.", "TWINT_OPEN": "Apri direttamente in TWINT", "TWINT_LINK": "Apri link di pagamento", "BANK_TITLE": "Bonifico Bancario",