fix(back-end) calculator improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 41s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 8s

This commit is contained in:
2026-02-25 15:05:23 +01:00
parent 54d12f4da0
commit fecb394272
14 changed files with 290 additions and 106 deletions

View File

@@ -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<String, Object> 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<QuoteLineItem> 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<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
Map<String, Object> 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<String, Object> 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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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<OrderItem> 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);

View File

@@ -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) {