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();
}
}

366
db.sql Normal file
View File

@@ -0,0 +1,366 @@
create table printer_machine
(
printer_machine_id bigserial primary key,
printer_display_name text not null unique,
build_volume_x_mm integer not null check (build_volume_x_mm > 0),
build_volume_y_mm integer not null check (build_volume_y_mm > 0),
build_volume_z_mm integer not null check (build_volume_z_mm > 0),
power_watts integer not null check (power_watts > 0),
fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create view printer_fleet_current as
select 1 as fleet_id,
case
when sum(fleet_weight) = 0 then null
else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer
end as weighted_average_power_watts,
max(build_volume_x_mm) as fleet_max_build_x_mm,
max(build_volume_y_mm) as fleet_max_build_y_mm,
max(build_volume_z_mm) as fleet_max_build_z_mm
from printer_machine
where is_active = true;
create table filament_material_type
(
filament_material_type_id bigserial primary key,
material_code text not null unique, -- PLA, PETG, TPU, ASA...
is_flexible boolean not null default false, -- sì/no
is_technical boolean not null default false, -- sì/no
technical_type_label text -- es: "alta temperatura", "rinforzato", ecc.
);
create table filament_variant
(
filament_variant_id bigserial primary key,
filament_material_type_id bigint not null references filament_material_type (filament_material_type_id),
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc.
is_matte boolean not null default false,
is_special boolean not null default false,
cost_chf_per_kg numeric(10, 2) not null,
-- Stock espresso in rotoli anche frazionati
stock_spools numeric(6, 3) not null default 0.000,
spool_net_kg numeric(6, 3) not null default 1.000,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (filament_material_type_id, variant_display_name)
);
-- (opzionale) kg disponibili calcolati
create view filament_variant_stock_kg as
select filament_variant_id,
stock_spools,
spool_net_kg,
(stock_spools * spool_net_kg) as stock_kg
from filament_variant;
create table pricing_policy
(
pricing_policy_id bigserial primary key,
policy_name text not null, -- es: "2026 Q1", "Default", ecc.
-- validità temporale (consiglio: valid_to esclusiva)
valid_from timestamptz not null,
valid_to timestamptz,
electricity_cost_chf_per_kwh numeric(10, 6) not null,
markup_percent numeric(6, 3) not null default 20.000,
fixed_job_fee_chf numeric(10, 2) not null default 0.00, -- "costo fisso"
nozzle_change_base_fee_chf numeric(10, 2) not null default 0.00, -- base cambio ugello, se vuoi
cad_cost_chf_per_hour numeric(10, 2) not null default 0.00,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table pricing_policy_machine_hour_tier
(
pricing_policy_machine_hour_tier_id bigserial primary key,
pricing_policy_id bigint not null references pricing_policy (pricing_policy_id),
tier_start_hours numeric(10, 2) not null,
tier_end_hours numeric(10, 2), -- null = infinito
machine_cost_chf_per_hour numeric(10, 2) not null,
constraint chk_tier_start_non_negative check (tier_start_hours >= 0),
constraint chk_tier_end_gt_start check (tier_end_hours is null or tier_end_hours > tier_start_hours)
);
create index idx_pricing_policy_validity
on pricing_policy (valid_from, valid_to);
create index idx_pricing_tier_lookup
on pricing_policy_machine_hour_tier (pricing_policy_id, tier_start_hours);
create table nozzle_option
(
nozzle_option_id bigserial primary key,
nozzle_diameter_mm numeric(4, 2) not null unique, -- 0.4, 0.6, 0.8...
owned_quantity integer not null default 0 check (owned_quantity >= 0),
-- extra costo specifico oltre ad eventuale base fee della pricing_policy
extra_nozzle_change_fee_chf numeric(10, 2) not null default 0.00,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table layer_height_option
(
layer_height_option_id bigserial primary key,
layer_height_mm numeric(5, 3) not null unique, -- 0.12, 0.20, 0.28...
-- opzionale: moltiplicatore costo/tempo (es: 0.12 costa di più)
time_multiplier numeric(6, 3) not null default 1.000,
is_active boolean not null default true
);
create table layer_height_profile
(
layer_height_profile_id bigserial primary key,
profile_name text not null unique, -- "Standard", "Fine", ecc.
min_layer_height_mm numeric(5, 3) not null,
max_layer_height_mm numeric(5, 3) not null,
default_layer_height_mm numeric(5, 3) not null,
time_multiplier numeric(6, 3) not null default 1.000,
constraint chk_layer_range check (max_layer_height_mm >= min_layer_height_mm)
);
begin;
set timezone = 'Europe/Zurich';
is_active = excluded.is_active;
-- =========================================================
-- 1) Pricing policy (valori ESATTI da Excel)
-- Valid from: 2026-01-01, valid_to: NULL
-- =========================================================
insert into pricing_policy (
policy_name,
valid_from,
valid_to,
electricity_cost_chf_per_kwh,
markup_percent,
fixed_job_fee_chf,
nozzle_change_base_fee_chf,
cad_cost_chf_per_hour,
is_active
) values (
'Excel Tariffe 2026-01-01',
'2026-01-01 00:00:00+01'::timestamptz,
null,
0.156, -- Costo elettricità CHF/kWh (Excel)
0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo)
1.00, -- Costo fisso macchina CHF (Excel)
0.00, -- Base cambio ugello: non specificato -> 0
25.00, -- Tariffa CAD CHF/h (Excel)
true
)
on conflict do nothing;
-- scaglioni tariffa stampa (Excel)
insert into pricing_policy_machine_hour_tier (
pricing_policy_id,
tier_start_hours,
tier_end_hours,
machine_cost_chf_per_hour
)
select
p.pricing_policy_id,
tiers.tier_start_hours,
tiers.tier_end_hours,
tiers.machine_cost_chf_per_hour
from pricing_policy p
cross join (
values
(0.00::numeric, 10.00::numeric, 2.00::numeric), -- 010 h
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 h
(20.00::numeric, null::numeric, 0.50::numeric) -- >20 h
) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour)
where p.policy_name = 'Excel Tariffe 2026-01-01'
on conflict do nothing;
-- =========================================================
-- 2) Stampante: BambuLab A1
-- =========================================================
insert into printer_machine (
printer_display_name,
build_volume_x_mm,
build_volume_y_mm,
build_volume_z_mm,
power_watts,
fleet_weight,
is_active
) values (
'BambuLab A1',
256,
256,
256,
150, -- hai detto "150, 140": qui ho messo 150
1.000,
true
)
on conflict (printer_display_name) do update
set
build_volume_x_mm = excluded.build_volume_x_mm,
build_volume_y_mm = excluded.build_volume_y_mm,
build_volume_z_mm = excluded.build_volume_z_mm,
power_watts = excluded.power_watts,
fleet_weight = excluded.fleet_weight,
is_active = excluded.is_active;
-- =========================================================
-- 3) Material types (da Excel) - per ora niente technical
-- =========================================================
insert into filament_material_type (
material_code,
is_flexible,
is_technical,
technical_type_label
) values
('PLA', false, false, null),
('PETG', false, false, null),
('TPU', true, false, null),
('ABS', false, false, null),
('Nylon', false, false, null),
('Carbon PLA', false, false, null)
on conflict (material_code) do update
set
is_flexible = excluded.is_flexible,
is_technical = excluded.is_technical,
technical_type_label = excluded.technical_type_label;
-- =========================================================
-- 4) Filament variants (PLA colori) - costi da Excel
-- Excel: PLA = 18 CHF/kg, TPU = 42 CHF/kg (non inserito perché quantità non chiara)
-- Stock in "rotoli" (3 = 3 kg se spool_net_kg=1)
-- =========================================================
-- helper: ID PLA
with pla as (
select filament_material_type_id
from filament_material_type
where material_code = 'PLA'
)
insert into filament_variant (
filament_material_type_id,
variant_display_name,
color_name,
is_matte,
is_special,
cost_chf_per_kg,
stock_spools,
spool_net_kg,
is_active
)
select
pla.filament_material_type_id,
v.variant_display_name,
v.color_name,
v.is_matte,
v.is_special,
18.00, -- PLA da Excel
v.stock_spools,
1.000,
true
from pla
cross join (
values
('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
('PLA Nero', 'Nero', false, false, 3.000::numeric),
('PLA Blu', 'Blu', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric),
('PLA Viola', 'Viola', false, false, 1.000::numeric)
) as v(variant_display_name, color_name, is_matte, is_special, stock_spools)
on conflict (filament_material_type_id, variant_display_name) do update
set
color_name = excluded.color_name,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
-- =========================================================
-- 5) Ugelli
-- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF
-- =========================================================
insert into nozzle_option (
nozzle_diameter_mm,
owned_quantity,
extra_nozzle_change_fee_chf,
is_active
) values
(0.40, 1, 0.00, true),
(0.60, 1, 50.00, true)
on conflict (nozzle_diameter_mm) do update
set
owned_quantity = excluded.owned_quantity,
extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
is_active = excluded.is_active;
-- =========================================================
-- 6) Layer heights (opzioni)
-- =========================================================
insert into layer_height_option (
layer_height_mm,
time_multiplier,
is_active
) values
(0.080, 1.000, true),
(0.120, 1.000, true),
(0.160, 1.000, true),
(0.200, 1.000, true),
(0.240, 1.000, true),
(0.280, 1.000, true)
on conflict (layer_height_mm) do update
set
time_multiplier = excluded.time_multiplier,
is_active = excluded.is_active;
commit;
-- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10)
update layer_height_option
set time_multiplier = 0.1
where layer_height_mm = 0.080;

View File

@@ -9,6 +9,10 @@ services:
ports:
- "8000:8000"
environment:
- DB_URL=jdbc:postgresql://db:5432/printcalc
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
@@ -16,10 +20,14 @@ services:
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
depends_on:
- db
restart: unless-stopped
frontend:
build: ./frontend
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: print-calculator-frontend
ports:
- "80:80"

15
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,15 @@
# Stage 1: Build
FROM node:20 as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Use development configuration to pick up environment.ts (localhost)
RUN npm run build -- --configuration=development
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -48,6 +48,7 @@
<label>COLORE</label>
<app-color-selector
[selectedColor]="item.color"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
@@ -80,20 +81,20 @@
<app-select
formControlName="material"
[label]="'CALC.MATERIAL' | translate"
[options]="materials"
[options]="materials()"
></app-select>
@if (mode() === 'easy') {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters"
[options]="nozzleDiameters()"
></app-select>
}
</div>
@@ -105,13 +106,13 @@
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights"
[options]="layerHeights()"
></app-select>
</div>
@@ -147,7 +148,7 @@
<app-button
type="submit"
[disabled]="form.invalid || items().length === 0 || loading()"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
</app-button>

View File

@@ -1,4 +1,4 @@
import { Component, input, output, signal, effect } from '@angular/core';
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -8,7 +8,7 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest } from '../../services/quote-estimator.service';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
@@ -24,69 +24,110 @@ interface FormItem {
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
})
export class UploadFormComponent {
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>();
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
form: FormGroup;
items = signal<FormItem[]>([]);
selectedFile = signal<File | null>(null);
materials = [
{ label: 'PLA (Standard)', value: 'PLA' },
{ label: 'PETG (Resistente)', value: 'PETG' },
{ label: 'TPU (Flessibile)', value: 'TPU' }
];
qualities = [
{ label: 'Bozza (Fast)', value: 'Draft' },
{ label: 'Standard', value: 'Standard' },
{ label: 'Alta definizione', value: 'High' }
];
nozzleDiameters = [
{ label: '0.2 mm (+2 CHF)', value: 0.2 },
{ label: '0.4 mm (Standard)', value: 0.4 },
{ label: '0.6 mm (+2 CHF)', value: 0.6 },
{ label: '0.8 mm (+2 CHF)', value: 0.8 }
];
// Dynamic Options
materials = signal<SimpleOption[]>([]);
qualities = signal<SimpleOption[]>([]);
nozzleDiameters = signal<SimpleOption[]>([]);
infillPatterns = signal<SimpleOption[]>([]);
layerHeights = signal<SimpleOption[]>([]);
infillPatterns = [
{ label: 'Grid', value: 'grid' },
{ label: 'Gyroid', value: 'gyroid' },
{ label: 'Cubic', value: 'cubic' },
{ label: 'Triangles', value: 'triangles' }
];
layerHeights = [
{ label: '0.08 mm', value: 0.08 },
{ label: '0.12 mm (High Quality - Slow)', value: 0.12 },
{ label: '0.16 mm', value: 0.16 },
{ label: '0.20 mm (Standard)', value: 0.20 },
{ label: '0.24 mm', value: 0.24 },
{ label: '0.28 mm', value: 0.28 }
];
// Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = [];
// Computed variants for valid material
currentMaterialVariants = signal<VariantOption[]>([]);
private updateVariants() {
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
} else {
this.currentMaterialVariants.set([]);
}
}
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
constructor(private fb: FormBuilder) {
constructor() {
this.form = this.fb.group({
itemsTouched: [false], // Hack to track touched state for custom items list
material: ['PLA', Validators.required],
quality: ['Standard', Validators.required],
// Print Speed removed
material: ['', Validators.required],
quality: ['', Validators.required],
items: [[]], // Track items in form for validation if needed
notes: [''],
// Advanced fields
// Color removed from global form
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
});
// Listen to material changes to update variants
this.form.get('material')?.valueChanges.subscribe(() => {
this.updateVariants();
});
}
ngOnInit() {
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
this.setDefaults();
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([{ label: 'PLA (Fallback)', value: 'PLA' }]);
this.qualities.set([{ label: 'Standard', value: 'standard' }]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
}
});
}
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
}
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
}
onFilesDropped(newFiles: File[]) {
@@ -187,13 +228,25 @@ export class UploadFormComponent {
}
onSubmit() {
console.log('UploadFormComponent: onSubmit triggered');
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
this.submitRequest.emit({
items: this.items(), // Pass the items array including colors
...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode()
});
} else {
console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
}
});
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
@@ -49,14 +49,70 @@ interface BackendResponse {
error?: string;
}
// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
}
export interface VariantOption {
name: string;
colorName: string;
hexColor: string;
isOutOfStock: boolean;
}
export interface QualityOption {
id: string;
label: string;
}
export interface InfillOption {
id: string;
label: string;
}
export interface NumericOption {
value: number;
label: string;
}
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
}
// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
tap({
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
})
);
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
if (request.items.length === 0) return of();
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
console.warn('QuoteEstimatorService: No items to calculate');
return of();
}
return new Observable(observer => {
const totalItems = request.items.length;
@@ -67,7 +123,24 @@ export class QuoteEstimatorService {
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
formData.append('machine', 'bambu_a1');
// machine param removed - backend uses default active
// Map material? Or trust frontend to send correct code?
// Since we fetch options now, we should send the code directly.
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
// If frontend sends 'PLA', mapMaterial returns 'pla_basic'.
// We should check if request.material is already a code from options.
// For now, let's assume request.material IS the code if it matches our new options,
// or fallback to mapper if it's old legacy string.
// Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes.
// For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'.
// Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA').
// Backend expects 'pla_basic' or just 'PLA'?
// QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'.
// So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code.
// Backend OptionsController returns type.getMaterialCode() which is 'PLA'.
// So we still need mapping to slicer profile names.
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
@@ -104,9 +177,6 @@ export class QuoteEstimatorService {
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
// Even if error, we count as complete
// But we need to handle completion logic carefully.
// For simplicity, let's treat it as complete but check later.
}
const event = wrapper.event;
@@ -159,8 +229,6 @@ export class QuoteEstimatorService {
});
if (items.length === 0) {
// If at least one failed? Or all?
// For now if NO items succeeded, error.
observer.error('All calculations failed.');
return;
}
@@ -196,7 +264,6 @@ export class QuoteEstimatorService {
},
error: (err) => {
console.error('Error in request subscription', err);
// Should be caught by inner pipe, but safety net
completedRequests++;
if (completedRequests === totalItems) {
observer.error('Requests failed');

View File

@@ -12,7 +12,7 @@
@if (isOpen()) {
<div class="color-popup">
@for (category of categories; track category.name) {
@for (category of categories(); track category.name) {
<div class="category">
<div class="category-name">{{ category.name }}</div>
<div class="colors-grid">

View File

@@ -1,7 +1,8 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
@Component({
selector: 'app-color-selector',
@@ -12,11 +13,28 @@ import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../.
})
export class ColorSelectorComponent {
selectedColor = input<string>('Black');
variants = input<VariantOption[]>([]);
colorSelected = output<string>();
isOpen = signal(false);
categories: ColorCategory[] = PRODUCT_COLORS;
categories = computed(() => {
const vars = this.variants();
if (vars && vars.length > 0) {
// Flatten variants into a single category for now
// We could try to group by extracting words, but "Colors" is fine.
return [{
name: 'Available Colors',
colors: vars.map(v => ({
label: v.colorName, // Display "Red"
value: v.colorName, // Send "Red" to backend
hex: v.hexColor,
outOfStock: v.isOutOfStock
}))
}] as ColorCategory[];
}
return PRODUCT_COLORS;
});
toggleOpen() {
this.isOpen.update(v => !v);
@@ -31,6 +49,13 @@ export class ColorSelectorComponent {
// Helper to find hex for the current selected value
getCurrentHex(): string {
// Check in dynamic variants first
const vars = this.variants();
if (vars && vars.length > 0) {
const found = vars.find(v => v.colorName === this.selectedColor());
if (found) return found.hexColor;
}
return getColorHex(this.selectedColor());
}

View File

@@ -1,5 +1,5 @@
export const environment = {
production: false,
apiUrl: 'https://dev.3d-fab.ch',
apiUrl: 'http://localhost:8000',
basicAuth: 'fab:0presura' // Format: 'username:password'
};