feat(back-end): db connections and other stuff
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 3s

This commit is contained in:
2026-02-10 19:07:37 +01:00
parent 3b4ef37e58
commit e5183590c5
42 changed files with 2015 additions and 182 deletions

View File

@@ -1,43 +0,0 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "pricing")
public class AppProperties {
private double filamentCostPerKg;
private double machineCostPerHour;
private double energyCostPerKwh;
private double printerPowerWatts;
private double markupPercent;
private String slicerPath;
private String profilesRoot;
// Getters and Setters needed for Spring binding
public double getFilamentCostPerKg() { return filamentCostPerKg; }
public void setFilamentCostPerKg(double filamentCostPerKg) { this.filamentCostPerKg = filamentCostPerKg; }
public double getMachineCostPerHour() { return machineCostPerHour; }
public void setMachineCostPerHour(double machineCostPerHour) { this.machineCostPerHour = machineCostPerHour; }
public double getEnergyCostPerKwh() { return energyCostPerKwh; }
public void setEnergyCostPerKwh(double energyCostPerKwh) { this.energyCostPerKwh = energyCostPerKwh; }
public double getPrinterPowerWatts() { return printerPowerWatts; }
public void setPrinterPowerWatts(double printerPowerWatts) { this.printerPowerWatts = printerPowerWatts; }
public double getMarkupPercent() { return markupPercent; }
public void setMarkupPercent(double markupPercent) { this.markupPercent = markupPercent; }
// Slicer props are not under "pricing" prefix in properties file?
// Wait, in application.properties I put them at root level/custom.
// Let's fix this class to map correctly or change prefix.
// I'll make a separate section or just bind manually.
// Actually, I'll just add @Value in services for simplicity or fix the prefix structure.
// Let's stick to standard @Value for simple paths if this is messy.
// Or better, creating a dedicated SlicerProperties.
}

View File

@@ -6,15 +6,14 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@Profile("local")
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(false);
.allowCredentials(true);
}
}

View File

@@ -1,13 +0,0 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "")
// Hack: standard prefix is usually required. I'll use @Value in service or correct this.
// Better: make SlicerConfig class.
public class SlicerConfig {
// Intentionally empty, will use @Value in service for simplicity
// or fix in next step.
}

View File

@@ -0,0 +1,125 @@
package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.*; // This line replaces specific entity imports
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.NozzleOptionRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@RestController
public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo;
public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo;
}
@GetMapping("/api/calculator/options")
public ResponseEntity<OptionsResponse> getOptions() {
// 1. Materials & Variants
List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findAll();
List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.map(type -> {
List<OptionsResponse.VariantOption> variants = allVariants.stream()
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
.map(v -> new OptionsResponse.VariantOption(
v.getVariantDisplayName(),
v.getColorName(),
getColorHex(v.getColorName()), // Need helper or store hex in DB
v.getStockSpools().doubleValue() <= 0
))
.collect(Collectors.toList());
// Only include material if it has active variants
if (variants.isEmpty()) return null;
return new OptionsResponse.MaterialOption(
type.getMaterialCode(),
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
variants
);
})
.filter(m -> m != null)
.collect(Collectors.toList());
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition")
);
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
);
// 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> l.getIsActive())
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm())
))
.collect(Collectors.toList());
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> n.getIsActive())
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(),
String.format("%.1f mm%s", n.getNozzleDiameterMm(),
n.getExtraNozzleChangeFeeChf().doubleValue() > 0
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)")
))
.collect(Collectors.toList());
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
}
// Temporary helper until we add hex to DB
private String getColorHex(String colorName) {
String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
if (lower.contains("blue") || lower.contains("blu")) return "#1976d2";
if (lower.contains("red") || lower.contains("rosso")) return "#d32f2f";
if (lower.contains("green") || lower.contains("verde")) return "#388e3c";
if (lower.contains("orange") || lower.contains("arancione")) return "#ffa726";
if (lower.contains("grey") || lower.contains("gray") || lower.contains("grigio")) {
if (lower.contains("dark") || lower.contains("scuro")) return "#424242";
return "#bdbdbd";
}
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; // Default grey
}
}

View File

@@ -1,13 +1,17 @@
package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -17,28 +21,33 @@ public class QuoteController {
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
// Defaults (using aliases defined in ProfileManager)
private static final String DEFAULT_MACHINE = "bambu_a1";
private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
}
@PostMapping("/api/quote")
public ResponseEntity<QuoteResult> calculateQuote(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "machine", required = false, defaultValue = DEFAULT_MACHINE) String machine,
@RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament,
@RequestParam(value = "process", required = false) String process,
@RequestParam(value = "quality", required = false) String quality
@RequestParam(value = "quality", required = false) String quality,
// Advanced Options
@RequestParam(value = "infill_density", required = false) Integer infillDensity,
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
@RequestParam(value = "layer_height", required = false) Double layerHeight,
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
) throws IOException {
// Frontend sends 'quality', backend expects 'process'.
// If process is missing, try quality. If both missing, use default.
// ... process selection logic ...
String actualProcess = process;
if (actualProcess == null || actualProcess.isEmpty()) {
if (quality != null && !quality.isEmpty()) {
@@ -48,7 +57,31 @@ public class QuoteController {
}
}
return processRequest(file, machine, filament, actualProcess);
// Prepare Overrides
Map<String, String> processOverrides = new HashMap<>();
Map<String, String> machineOverrides = new HashMap<>();
if (infillDensity != null) {
processOverrides.put("sparse_infill_density", infillDensity + "%");
}
if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern);
}
if (layerHeight != null) {
processOverrides.put("layer_height", String.valueOf(layerHeight));
}
if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
}
if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter));
// Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile.
}
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
}
@PostMapping("/calculate/stl")
@@ -56,30 +89,37 @@ public class QuoteController {
@RequestParam("file") MultipartFile file
) throws IOException {
// Legacy endpoint uses defaults
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
}
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides,
Map<String, String> processOverrides) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Fetch Default Active Machine
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new IOException("No active printer found in database"));
// Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
try {
file.transferTo(tempInput.toFile());
// Slice
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats);
// Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
return ResponseEntity.internalServerError().build();
} finally {
Files.deleteIfExists(tempInput);
}

View File

@@ -0,0 +1,18 @@
package com.printcalculator.dto;
import java.util.List;
public record OptionsResponse(
List<MaterialOption> materials,
List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters
) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
public record QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {}
}

View File

@@ -0,0 +1,68 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "filament_material_type")
public class FilamentMaterialType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_material_type_id", nullable = false)
private Long id;
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode;
@ColumnDefault("false")
@Column(name = "is_flexible", nullable = false)
private Boolean isFlexible;
@ColumnDefault("false")
@Column(name = "is_technical", nullable = false)
private Boolean isTechnical;
@Column(name = "technical_type_label", length = Integer.MAX_VALUE)
private String technicalTypeLabel;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,142 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "filament_variant")
public class FilamentVariant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_variant_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_material_type_id", nullable = false)
private FilamentMaterialType filamentMaterialType;
@Column(name = "variant_display_name", nullable = false, length = Integer.MAX_VALUE)
private String variantDisplayName;
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName;
@ColumnDefault("false")
@Column(name = "is_matte", nullable = false)
private Boolean isMatte;
@ColumnDefault("false")
@Column(name = "is_special", nullable = false)
private Boolean isSpecial;
@Column(name = "cost_chf_per_kg", nullable = false, precision = 10, scale = 2)
private BigDecimal costChfPerKg;
@ColumnDefault("0.000")
@Column(name = "stock_spools", nullable = false, precision = 6, scale = 3)
private BigDecimal stockSpools;
@ColumnDefault("1.000")
@Column(name = "spool_net_kg", nullable = false, precision = 6, scale = 3)
private BigDecimal spoolNetKg;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public FilamentMaterialType getFilamentMaterialType() {
return filamentMaterialType;
}
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
this.filamentMaterialType = filamentMaterialType;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,44 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Immutable;
import java.math.BigDecimal;
@Entity
@Immutable
@Table(name = "filament_variant_stock_kg")
public class FilamentVariantStockKg {
@Id
@Column(name = "filament_variant_id")
private Long filamentVariantId;
@Column(name = "stock_spools", precision = 6, scale = 3)
private BigDecimal stockSpools;
@Column(name = "spool_net_kg", precision = 6, scale = 3)
private BigDecimal spoolNetKg;
@Column(name = "stock_kg")
private BigDecimal stockKg;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
}

View File

@@ -0,0 +1,56 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "infill_pattern")
public class InfillPattern {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "infill_pattern_id", nullable = false)
private Long id;
@Column(name = "pattern_code", nullable = false, length = Integer.MAX_VALUE)
private String patternCode;
@Column(name = "display_name", nullable = false, length = Integer.MAX_VALUE)
private String displayName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPatternCode() {
return patternCode;
}
public void setPatternCode(String patternCode) {
this.patternCode = patternCode;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,59 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "layer_height_option")
public class LayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "layer_height_option_id", nullable = false)
private Long id;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("1.000")
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
private BigDecimal timeMultiplier;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public BigDecimal getTimeMultiplier() {
return timeMultiplier;
}
public void setTimeMultiplier(BigDecimal timeMultiplier) {
this.timeMultiplier = timeMultiplier;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,80 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "layer_height_profile")
public class LayerHeightProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "layer_height_profile_id", nullable = false)
private Long id;
@Column(name = "profile_name", nullable = false, length = Integer.MAX_VALUE)
private String profileName;
@Column(name = "min_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal minLayerHeightMm;
@Column(name = "max_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal maxLayerHeightMm;
@Column(name = "default_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal defaultLayerHeightMm;
@ColumnDefault("1.000")
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
private BigDecimal timeMultiplier;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getProfileName() {
return profileName;
}
public void setProfileName(String profileName) {
this.profileName = profileName;
}
public BigDecimal getMinLayerHeightMm() {
return minLayerHeightMm;
}
public void setMinLayerHeightMm(BigDecimal minLayerHeightMm) {
this.minLayerHeightMm = minLayerHeightMm;
}
public BigDecimal getMaxLayerHeightMm() {
return maxLayerHeightMm;
}
public void setMaxLayerHeightMm(BigDecimal maxLayerHeightMm) {
this.maxLayerHeightMm = maxLayerHeightMm;
}
public BigDecimal getDefaultLayerHeightMm() {
return defaultLayerHeightMm;
}
public void setDefaultLayerHeightMm(BigDecimal defaultLayerHeightMm) {
this.defaultLayerHeightMm = defaultLayerHeightMm;
}
public BigDecimal getTimeMultiplier() {
return timeMultiplier;
}
public void setTimeMultiplier(BigDecimal timeMultiplier) {
this.timeMultiplier = timeMultiplier;
}
}

View File

@@ -0,0 +1,84 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "nozzle_option")
public class NozzleOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@ColumnDefault("0")
@Column(name = "owned_quantity", nullable = false)
private Integer ownedQuantity;
@ColumnDefault("0.00")
@Column(name = "extra_nozzle_change_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal extraNozzleChangeFeeChf;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public Integer getOwnedQuantity() {
return ownedQuantity;
}
public void setOwnedQuantity(Integer ownedQuantity) {
this.ownedQuantity = ownedQuantity;
}
public BigDecimal getExtraNozzleChangeFeeChf() {
return extraNozzleChangeFeeChf;
}
public void setExtraNozzleChangeFeeChf(BigDecimal extraNozzleChangeFeeChf) {
this.extraNozzleChangeFeeChf = extraNozzleChangeFeeChf;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,141 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "pricing_policy")
public class PricingPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pricing_policy_id", nullable = false)
private Long id;
@Column(name = "policy_name", nullable = false, length = Integer.MAX_VALUE)
private String policyName;
@Column(name = "valid_from", nullable = false)
private OffsetDateTime validFrom;
@Column(name = "valid_to")
private OffsetDateTime validTo;
@Column(name = "electricity_cost_chf_per_kwh", nullable = false, precision = 10, scale = 6)
private BigDecimal electricityCostChfPerKwh;
@ColumnDefault("20.000")
@Column(name = "markup_percent", nullable = false, precision = 6, scale = 3)
private BigDecimal markupPercent;
@ColumnDefault("0.00")
@Column(name = "fixed_job_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal fixedJobFeeChf;
@ColumnDefault("0.00")
@Column(name = "nozzle_change_base_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal nozzleChangeBaseFeeChf;
@ColumnDefault("0.00")
@Column(name = "cad_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
private BigDecimal cadCostChfPerHour;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPolicyName() {
return policyName;
}
public void setPolicyName(String policyName) {
this.policyName = policyName;
}
public OffsetDateTime getValidFrom() {
return validFrom;
}
public void setValidFrom(OffsetDateTime validFrom) {
this.validFrom = validFrom;
}
public OffsetDateTime getValidTo() {
return validTo;
}
public void setValidTo(OffsetDateTime validTo) {
this.validTo = validTo;
}
public BigDecimal getElectricityCostChfPerKwh() {
return electricityCostChfPerKwh;
}
public void setElectricityCostChfPerKwh(BigDecimal electricityCostChfPerKwh) {
this.electricityCostChfPerKwh = electricityCostChfPerKwh;
}
public BigDecimal getMarkupPercent() {
return markupPercent;
}
public void setMarkupPercent(BigDecimal markupPercent) {
this.markupPercent = markupPercent;
}
public BigDecimal getFixedJobFeeChf() {
return fixedJobFeeChf;
}
public void setFixedJobFeeChf(BigDecimal fixedJobFeeChf) {
this.fixedJobFeeChf = fixedJobFeeChf;
}
public BigDecimal getNozzleChangeBaseFeeChf() {
return nozzleChangeBaseFeeChf;
}
public void setNozzleChangeBaseFeeChf(BigDecimal nozzleChangeBaseFeeChf) {
this.nozzleChangeBaseFeeChf = nozzleChangeBaseFeeChf;
}
public BigDecimal getCadCostChfPerHour() {
return cadCostChfPerHour;
}
public void setCadCostChfPerHour(BigDecimal cadCostChfPerHour) {
this.cadCostChfPerHour = cadCostChfPerHour;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,68 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "pricing_policy_machine_hour_tier")
public class PricingPolicyMachineHourTier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pricing_policy_machine_hour_tier_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pricing_policy_id", nullable = false)
private PricingPolicy pricingPolicy;
@Column(name = "tier_start_hours", nullable = false, precision = 10, scale = 2)
private BigDecimal tierStartHours;
@Column(name = "tier_end_hours", precision = 10, scale = 2)
private BigDecimal tierEndHours;
@Column(name = "machine_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
private BigDecimal machineCostChfPerHour;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PricingPolicy getPricingPolicy() {
return pricingPolicy;
}
public void setPricingPolicy(PricingPolicy pricingPolicy) {
this.pricingPolicy = pricingPolicy;
}
public BigDecimal getTierStartHours() {
return tierStartHours;
}
public void setTierStartHours(BigDecimal tierStartHours) {
this.tierStartHours = tierStartHours;
}
public BigDecimal getTierEndHours() {
return tierEndHours;
}
public void setTierEndHours(BigDecimal tierEndHours) {
this.tierEndHours = tierEndHours;
}
public BigDecimal getMachineCostChfPerHour() {
return machineCostChfPerHour;
}
public void setMachineCostChfPerHour(BigDecimal machineCostChfPerHour) {
this.machineCostChfPerHour = machineCostChfPerHour;
}
}

View File

@@ -0,0 +1,46 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import org.hibernate.annotations.Immutable;
import jakarta.persistence.Id;
@Entity
@Immutable
@Table(name = "printer_fleet_current")
public class PrinterFleetCurrent {
@Id
@Column(name = "fleet_id")
private Long id;
@Column(name = "weighted_average_power_watts")
private Integer weightedAveragePowerWatts;
@Column(name = "fleet_max_build_x_mm")
private Integer fleetMaxBuildXMm;
@Column(name = "fleet_max_build_y_mm")
private Integer fleetMaxBuildYMm;
@Column(name = "fleet_max_build_z_mm")
private Integer fleetMaxBuildZMm;
public Integer getWeightedAveragePowerWatts() {
return weightedAveragePowerWatts;
}
public Integer getFleetMaxBuildXMm() {
return fleetMaxBuildXMm;
}
public Integer getFleetMaxBuildYMm() {
return fleetMaxBuildYMm;
}
public Integer getFleetMaxBuildZMm() {
return fleetMaxBuildZMm;
}
}

View File

@@ -0,0 +1,116 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "printer_machine")
public class PrinterMachine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "printer_machine_id", nullable = false)
private Long id;
@Column(name = "printer_display_name", nullable = false, length = Integer.MAX_VALUE)
private String printerDisplayName;
@Column(name = "build_volume_x_mm", nullable = false)
private Integer buildVolumeXMm;
@Column(name = "build_volume_y_mm", nullable = false)
private Integer buildVolumeYMm;
@Column(name = "build_volume_z_mm", nullable = false)
private Integer buildVolumeZMm;
@Column(name = "power_watts", nullable = false)
private Integer powerWatts;
@ColumnDefault("1.000")
@Column(name = "fleet_weight", nullable = false, precision = 6, scale = 3)
private BigDecimal fleetWeight;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPrinterDisplayName() {
return printerDisplayName;
}
public void setPrinterDisplayName(String printerDisplayName) {
this.printerDisplayName = printerDisplayName;
}
public Integer getBuildVolumeXMm() {
return buildVolumeXMm;
}
public void setBuildVolumeXMm(Integer buildVolumeXMm) {
this.buildVolumeXMm = buildVolumeXMm;
}
public Integer getBuildVolumeYMm() {
return buildVolumeYMm;
}
public void setBuildVolumeYMm(Integer buildVolumeYMm) {
this.buildVolumeYMm = buildVolumeYMm;
}
public Integer getBuildVolumeZMm() {
return buildVolumeZMm;
}
public void setBuildVolumeZMm(Integer buildVolumeZMm) {
this.buildVolumeZMm = buildVolumeZMm;
}
public Integer getPowerWatts() {
return powerWatts;
}
public void setPowerWatts(Integer powerWatts) {
this.powerWatts = powerWatts;
}
public BigDecimal getFleetWeight() {
return fleetWeight;
}
public void setFleetWeight(BigDecimal fleetWeight) {
this.fleetWeight = fleetWeight;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -7,5 +7,5 @@ public record CostBreakdown(
BigDecimal machineCost,
BigDecimal energyCost,
BigDecimal subtotal,
BigDecimal markupAmount
BigDecimal markup
) {}

View File

@@ -1,12 +1,51 @@
package com.printcalculator.model;
import java.math.BigDecimal;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
public record QuoteResult(
BigDecimal totalPrice,
String currency,
PrintStats stats,
CostBreakdown breakdown,
List<String> notes
) {}
public class QuoteResult {
private double totalPrice;
private String currency;
private PrintStats stats;
@JsonIgnore
private CostBreakdown breakdown;
@JsonIgnore
private List<String> notes;
private double setupCost;
public QuoteResult(double totalPrice, String currency, PrintStats stats, CostBreakdown breakdown, List<String> notes, double setupCost) {
this.totalPrice = totalPrice;
this.currency = currency;
this.stats = stats;
this.breakdown = breakdown;
this.notes = notes;
this.setupCost = setupCost;
}
public double getTotalPrice() {
return totalPrice;
}
public String getCurrency() {
return currency;
}
public PrintStats getStats() {
return stats;
}
public CostBreakdown getBreakdown() {
return breakdown;
}
public List<String> getNotes() {
return notes;
}
public double getSetupCost() {
return setupCost;
}
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentMaterialType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FilamentMaterialTypeRepository extends JpaRepository<FilamentMaterialType, Long> {
Optional<FilamentMaterialType> findByMaterialCode(String materialCode);
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import com.printcalculator.entity.FilamentMaterialType;
import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
// We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.InfillPattern;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InfillPatternRepository extends JpaRepository<InfillPattern, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.LayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LayerHeightOptionRepository extends JpaRepository<LayerHeightOption, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.LayerHeightProfile;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LayerHeightProfileRepository extends JpaRepository<LayerHeightProfile, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleOption;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PricingPolicyMachineHourTier;
import org.springframework.data.jpa.repository.JpaRepository;
import com.printcalculator.entity.PricingPolicy;
import java.util.List;
public interface PricingPolicyMachineHourTierRepository extends JpaRepository<PricingPolicyMachineHourTier, Long> {
List<PricingPolicyMachineHourTier> findAllByPricingPolicyOrderByTierStartHoursAsc(PricingPolicy pricingPolicy);
}

View File

@@ -0,0 +1,8 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PricingPolicy;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
PricingPolicy findFirstByIsActiveTrueOrderByValidFromDesc();
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterMachine;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PrinterMachineRepository extends JpaRepository<PrinterMachine, Long> {
Optional<PrinterMachine> findByPrinterDisplayName(String printerDisplayName);
Optional<PrinterMachine> findFirstByIsActiveTrue();
}

View File

@@ -17,7 +17,7 @@ public class GCodeParser {
// ; estimated printing time = 1h 2m 3s
// ; filament used [g] = 12.34
// ; filament used [mm] = 1234.56
private static final Pattern TIME_PATTERN = Pattern.compile(";\\s*estimated printing time\\s*=\\s*(.*)");
private static final Pattern TIME_PATTERN = Pattern.compile(";\\s*estimated printing time.*=\\s*(.*)", Pattern.CASE_INSENSITIVE);
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
@@ -29,25 +29,32 @@ public class GCodeParser {
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
String line;
// Scan first 5000 lines for efficiency (metadata might be further down)
int count = 0;
while ((line = reader.readLine()) != null && count < 5000) {
// Scan entire file as metadata is often at the end
while ((line = reader.readLine()) != null) {
line = line.trim();
// OrcaSlicer comments start with ;
if (!line.startsWith(";")) {
count++;
continue;
}
if (line.toLowerCase().contains("estimated printing time")) {
System.out.println("DEBUG: Found potential time line: '" + line + "'");
}
Matcher timeMatcher = TIME_PATTERN.matcher(line);
if (timeMatcher.find()) {
timeFormatted = timeMatcher.group(1).trim();
seconds = parseTimeString(timeFormatted);
System.out.println("GCodeParser: Found time: " + timeFormatted + " (" + seconds + "s)");
}
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
if (weightMatcher.find()) {
try {
weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found weight: " + weightG + "g");
} catch (NumberFormatException ignored) {}
}
@@ -55,9 +62,9 @@ public class GCodeParser {
if (lengthMatcher.find()) {
try {
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
System.out.println("GCodeParser: Found length: " + lengthMm + "mm");
} catch (NumberFormatException ignored) {}
}
count++;
}
}

View File

@@ -1,59 +1,177 @@
package com.printcalculator.service;
import com.printcalculator.config.AppProperties;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.PricingPolicyMachineHourTier;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.CostBreakdown;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PricingPolicyMachineHourTierRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@Service
public class QuoteCalculator {
private final AppProperties props;
private final PricingPolicyRepository pricingRepo;
private final PricingPolicyMachineHourTierRepository tierRepo;
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
public QuoteCalculator(AppProperties props) {
this.props = props;
public QuoteCalculator(PricingPolicyRepository pricingRepo,
PricingPolicyMachineHourTierRepository tierRepo,
PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo) {
this.pricingRepo = pricingRepo;
this.tierRepo = tierRepo;
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
}
public QuoteResult calculate(PrintStats stats) {
public QuoteResult calculate(PrintStats stats, String machineName, String filamentProfileName) {
// 1. Fetch Active Policy
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) {
throw new RuntimeException("No active pricing policy found");
}
// 2. Fetch Machine Info
// Map "bambu_a1" -> "BambuLab A1" or similar?
// Ideally we should use the display name from DB.
// For now, if machineName is a code, we might need a mapping or just fuzzy search.
// Let's assume machineName is mapped or we search by display name.
// If not found, fallback to first active.
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
if (machine == null) {
// Try "BambuLab A1" if code was "bambu_a1" logic or just get first active
machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
// 3. Fetch Filament Info
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
// We try to extract material code (PLA, PETG)
String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
// Try to find specific variant (e.g. by color if we could parse it)
// For now, get default/first active variant for this material
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
// --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(BigDecimal.valueOf(props.getFilamentCostPerKg()));
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: (seconds / 3600) * costPerHour
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
// Machine Cost: Tiered
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(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(hours);
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
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
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
// Subtotal (Costs + Fixed Fees)
BigDecimal fixedFee = policy.getFixedJobFeeChf();
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
// Markup
BigDecimal markupFactor = BigDecimal.valueOf(1.0 + (props.getMarkupPercent() / 100.0));
// 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);
BigDecimal markupAmount = totalPrice.subtract(subtotal);
CostBreakdown breakdown = new CostBreakdown(
materialCost.setScale(2, RoundingMode.HALF_UP),
machineCost.setScale(2, RoundingMode.HALF_UP),
energyCost.setScale(2, RoundingMode.HALF_UP),
subtotal.setScale(2, RoundingMode.HALF_UP),
markupAmount.setScale(2, RoundingMode.HALF_UP)
materialCost.setScale(2, RoundingMode.HALF_UP),
machineCost.setScale(2, RoundingMode.HALF_UP),
energyCost.setScale(2, RoundingMode.HALF_UP),
subtotal.setScale(2, RoundingMode.HALF_UP),
markupAmount.setScale(2, RoundingMode.HALF_UP)
);
List<String> notes = new ArrayList<>();
// notes.add("Generated via Dynamic Slicer (Java Backend)");
return new QuoteResult(totalPrice, "CHF", stats, breakdown, notes);
List<String> notes = new ArrayList<>();
notes.add("Policy: " + policy.getPolicyName());
notes.add("Machine: " + machine.getPrinterDisplayName());
notes.add("Material: " + variant.getVariantDisplayName());
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, breakdown, notes, fixedFee.doubleValue());
}
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
List<PricingPolicyMachineHourTier> tiers = tierRepo.findAllByPricingPolicyOrderByTierStartHoursAsc(policy);
if (tiers.isEmpty()) {
return BigDecimal.ZERO; // Should not happen if DB is correct
}
BigDecimal remainingHours = hours;
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal processedHours = BigDecimal.ZERO;
for (PricingPolicyMachineHourTier tier : tiers) {
if (remainingHours.compareTo(BigDecimal.ZERO) <= 0) break;
BigDecimal tierStart = tier.getTierStartHours();
BigDecimal tierEnd = tier.getTierEndHours(); // can be null for infinity
// Determine duration in this tier
// Valid duration in this tier = (min(tierEnd, totalHours) - tierStart)
// But logic is simpler: we consume hours sequentially?
// "0-10h @ 2CHF, 10-20h @ 1.5CHF" implies:
// 5h job -> 5 * 2
// 15h job -> 10 * 2 + 5 * 1.5
BigDecimal tierDuration;
// Max hours applicable in this tier relative to 0
BigDecimal tierLimit = (tierEnd != null) ? tierEnd : BigDecimal.valueOf(Long.MAX_VALUE);
// The amount of hours falling into this bucket
// Upper bound for this calculation is min(totalHours, tierLimit)
// Lower bound is tierStart
// So hours in this bucket = max(0, min(totalHours, tierLimit) - tierStart)
BigDecimal upper = hours.min(tierLimit);
BigDecimal lower = tierStart;
if (upper.compareTo(lower) > 0) {
BigDecimal hoursInTier = upper.subtract(lower);
totalCost = totalCost.add(hoursInTier.multiply(tier.getMachineCostChfPerHour()));
}
}
return totalCost;
}
private String detectMaterialCode(String profileName) {
String lower = profileName.toLowerCase();
if (lower.contains("petg")) return "PETG";
if (lower.contains("tpu")) return "TPU";
if (lower.contains("abs")) return "ABS";
if (lower.contains("nylon")) return "Nylon";
if (lower.contains("asa")) return "ASA";
// Default to PLA
return "PLA";
}
}

View File

@@ -12,6 +12,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
@@ -36,12 +37,21 @@ public class SlicerService {
this.mapper = mapper;
}
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
// Apply Overrides
if (machineOverrides != null) {
machineOverrides.forEach(machineProfile::put);
}
if (processOverrides != null) {
processOverrides.forEach(processProfile::put);
}
// 2. Create Temp Dir
Path tempDir = Files.createTempDirectory("slicer_job_");
try {

View File

@@ -14,13 +14,6 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
profiles.root=${PROFILES_DIR:profiles}
# Pricing Configuration
# Mapped to legacy environment variables for Docker compatibility
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
pricing.markup-percent=${MARKUP_PERCENT:20.0}
# File Upload Limits
spring.servlet.multipart.max-file-size=200MB

View File

@@ -52,6 +52,26 @@ class GCodeParserTest {
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
tempFile.delete();
}
@Test
void parse_withExtraTextInTimeLine_returnsCorrectStats() throws IOException {
// Arrange
File tempFile = File.createTempFile("test_extra", ".gcode");
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write("; generated by OrcaSlicer\n");
// Simulate the variation that was causing issues
writer.write("; estimated printing time (normal mode) = 1h 2m 3s\n");
writer.write("; filament used [g] = 10.5\n");
writer.write("; filament used [mm] = 3000.0\n");
}
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds());
assertEquals("1h 2m 3s", stats.printTimeFormatted());
tempFile.delete();
}
}