From e5183590c573b3e9960e05f422ed925f47007011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 10 Feb 2026 19:07:37 +0100 Subject: [PATCH 01/19] feat(back-end): db connections and other stuff --- .../printcalculator/config/AppProperties.java | 43 -- .../printcalculator/config/CorsConfig.java | 5 +- .../printcalculator/config/SlicerConfig.java | 13 - .../controller/OptionsController.java | 125 ++++++ .../controller/QuoteController.java | 68 +++- .../printcalculator/dto/OptionsResponse.java | 18 + .../entity/FilamentMaterialType.java | 68 ++++ .../entity/FilamentVariant.java | 142 +++++++ .../entity/FilamentVariantStockKg.java | 44 +++ .../printcalculator/entity/InfillPattern.java | 56 +++ .../entity/LayerHeightOption.java | 59 +++ .../entity/LayerHeightProfile.java | 80 ++++ .../printcalculator/entity/NozzleOption.java | 84 ++++ .../printcalculator/entity/PricingPolicy.java | 141 +++++++ .../entity/PricingPolicyMachineHourTier.java | 68 ++++ .../entity/PrinterFleetCurrent.java | 46 +++ .../entity/PrinterMachine.java | 116 ++++++ .../printcalculator/model/CostBreakdown.java | 2 +- .../printcalculator/model/QuoteResult.java | 55 ++- .../FilamentMaterialTypeRepository.java | 10 + .../repository/FilamentVariantRepository.java | 13 + .../repository/InfillPatternRepository.java | 7 + .../LayerHeightOptionRepository.java | 7 + .../LayerHeightProfileRepository.java | 7 + .../repository/NozzleOptionRepository.java | 7 + ...ricingPolicyMachineHourTierRepository.java | 11 + .../repository/PricingPolicyRepository.java | 8 + .../repository/PrinterMachineRepository.java | 11 + .../printcalculator/service/GCodeParser.java | 19 +- .../service/QuoteCalculator.java | 166 ++++++-- .../service/SlicerService.java | 12 +- .../src/main/resources/application.properties | 7 - .../service/GCodeParserTest.java | 20 + db.sql | 366 ++++++++++++++++++ docker-compose.yml | 10 +- frontend/Dockerfile.dev | 15 + .../upload-form/upload-form.component.html | 13 +- .../upload-form/upload-form.component.ts | 137 +++++-- .../services/quote-estimator.service.ts | 85 +++- .../color-selector.component.html | 2 +- .../color-selector.component.ts | 29 +- frontend/src/environments/environment.ts | 2 +- 42 files changed, 2015 insertions(+), 182 deletions(-) delete mode 100644 backend/src/main/java/com/printcalculator/config/AppProperties.java delete mode 100644 backend/src/main/java/com/printcalculator/config/SlicerConfig.java create mode 100644 backend/src/main/java/com/printcalculator/controller/OptionsController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/OptionsResponse.java create mode 100644 backend/src/main/java/com/printcalculator/entity/FilamentMaterialType.java create mode 100644 backend/src/main/java/com/printcalculator/entity/FilamentVariant.java create mode 100644 backend/src/main/java/com/printcalculator/entity/FilamentVariantStockKg.java create mode 100644 backend/src/main/java/com/printcalculator/entity/InfillPattern.java create mode 100644 backend/src/main/java/com/printcalculator/entity/LayerHeightOption.java create mode 100644 backend/src/main/java/com/printcalculator/entity/LayerHeightProfile.java create mode 100644 backend/src/main/java/com/printcalculator/entity/NozzleOption.java create mode 100644 backend/src/main/java/com/printcalculator/entity/PricingPolicy.java create mode 100644 backend/src/main/java/com/printcalculator/entity/PricingPolicyMachineHourTier.java create mode 100644 backend/src/main/java/com/printcalculator/entity/PrinterFleetCurrent.java create mode 100644 backend/src/main/java/com/printcalculator/entity/PrinterMachine.java create mode 100644 backend/src/main/java/com/printcalculator/repository/FilamentMaterialTypeRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/InfillPatternRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/LayerHeightOptionRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/LayerHeightProfileRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/PricingPolicyMachineHourTierRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/PricingPolicyRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/PrinterMachineRepository.java create mode 100644 db.sql create mode 100644 frontend/Dockerfile.dev diff --git a/backend/src/main/java/com/printcalculator/config/AppProperties.java b/backend/src/main/java/com/printcalculator/config/AppProperties.java deleted file mode 100644 index ab21cf8..0000000 --- a/backend/src/main/java/com/printcalculator/config/AppProperties.java +++ /dev/null @@ -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. -} diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index 39eaea5..2e6f92c 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -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); } } diff --git a/backend/src/main/java/com/printcalculator/config/SlicerConfig.java b/backend/src/main/java/com/printcalculator/config/SlicerConfig.java deleted file mode 100644 index ba69263..0000000 --- a/backend/src/main/java/com/printcalculator/config/SlicerConfig.java +++ /dev/null @@ -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. -} diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java new file mode 100644 index 0000000..7eb7251 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -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 getOptions() { + // 1. Materials & Variants + List types = materialRepo.findAll(); + List allVariants = variantRepo.findAll(); + + List materialOptions = types.stream() + .map(type -> { + List 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 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 patterns = List.of( + new OptionsResponse.InfillPatternOption("grid", "Grid"), + new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), + new OptionsResponse.InfillPatternOption("cubic", "Cubic") + ); + + // 4. Layer Heights + List 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 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 + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index b85a6a6..018b613 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -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 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 processOverrides = new HashMap<>(); + Map 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 processRequest(MultipartFile file, String machine, String filament, String process) throws IOException { + private ResponseEntity processRequest(MultipartFile file, String filament, String process, + Map machineOverrides, + Map 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); } diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java new file mode 100644 index 0000000..dc60fb7 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -0,0 +1,18 @@ +package com.printcalculator.dto; + +import java.util.List; + +public record OptionsResponse( + List materials, + List qualities, + List infillPatterns, + List layerHeights, + List nozzleDiameters +) { + public record MaterialOption(String code, String label, List 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) {} +} diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentMaterialType.java b/backend/src/main/java/com/printcalculator/entity/FilamentMaterialType.java new file mode 100644 index 0000000..a2ac170 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/FilamentMaterialType.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java new file mode 100644 index 0000000..22c9003 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariantStockKg.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariantStockKg.java new file mode 100644 index 0000000..e377ae9 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariantStockKg.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/InfillPattern.java b/backend/src/main/java/com/printcalculator/entity/InfillPattern.java new file mode 100644 index 0000000..3f2c656 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/InfillPattern.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/LayerHeightOption.java b/backend/src/main/java/com/printcalculator/entity/LayerHeightOption.java new file mode 100644 index 0000000..f25ef55 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/LayerHeightOption.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/LayerHeightProfile.java b/backend/src/main/java/com/printcalculator/entity/LayerHeightProfile.java new file mode 100644 index 0000000..05d0f96 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/LayerHeightProfile.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/NozzleOption.java b/backend/src/main/java/com/printcalculator/entity/NozzleOption.java new file mode 100644 index 0000000..0153250 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/NozzleOption.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/PricingPolicy.java b/backend/src/main/java/com/printcalculator/entity/PricingPolicy.java new file mode 100644 index 0000000..5df4323 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PricingPolicy.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/PricingPolicyMachineHourTier.java b/backend/src/main/java/com/printcalculator/entity/PricingPolicyMachineHourTier.java new file mode 100644 index 0000000..9760113 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PricingPolicyMachineHourTier.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/PrinterFleetCurrent.java b/backend/src/main/java/com/printcalculator/entity/PrinterFleetCurrent.java new file mode 100644 index 0000000..a02b50a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PrinterFleetCurrent.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java b/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java new file mode 100644 index 0000000..8d378df --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PrinterMachine.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/model/CostBreakdown.java b/backend/src/main/java/com/printcalculator/model/CostBreakdown.java index 64eab4f..3cd294c 100644 --- a/backend/src/main/java/com/printcalculator/model/CostBreakdown.java +++ b/backend/src/main/java/com/printcalculator/model/CostBreakdown.java @@ -7,5 +7,5 @@ public record CostBreakdown( BigDecimal machineCost, BigDecimal energyCost, BigDecimal subtotal, - BigDecimal markupAmount + BigDecimal markup ) {} diff --git a/backend/src/main/java/com/printcalculator/model/QuoteResult.java b/backend/src/main/java/com/printcalculator/model/QuoteResult.java index 22c242b..6d90908 100644 --- a/backend/src/main/java/com/printcalculator/model/QuoteResult.java +++ b/backend/src/main/java/com/printcalculator/model/QuoteResult.java @@ -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 notes -) {} +public class QuoteResult { + private double totalPrice; + private String currency; + private PrintStats stats; + + @JsonIgnore + private CostBreakdown breakdown; + + @JsonIgnore + private List notes; + + private double setupCost; + + public QuoteResult(double totalPrice, String currency, PrintStats stats, CostBreakdown breakdown, List 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 getNotes() { + return notes; + } + + public double getSetupCost() { + return setupCost; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentMaterialTypeRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentMaterialTypeRepository.java new file mode 100644 index 0000000..a83b1bc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentMaterialTypeRepository.java @@ -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 { + Optional findByMaterialCode(String materialCode); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java new file mode 100644 index 0000000..1c04817 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java @@ -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 { + // We try to match by color name if possible, or get first active + Optional findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); + Optional findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/InfillPatternRepository.java b/backend/src/main/java/com/printcalculator/repository/InfillPatternRepository.java new file mode 100644 index 0000000..4c78b8d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/InfillPatternRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/LayerHeightOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/LayerHeightOptionRepository.java new file mode 100644 index 0000000..33293fd --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/LayerHeightOptionRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/LayerHeightProfileRepository.java b/backend/src/main/java/com/printcalculator/repository/LayerHeightProfileRepository.java new file mode 100644 index 0000000..6a1f560 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/LayerHeightProfileRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java new file mode 100644 index 0000000..6cfdbb6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PricingPolicyMachineHourTierRepository.java b/backend/src/main/java/com/printcalculator/repository/PricingPolicyMachineHourTierRepository.java new file mode 100644 index 0000000..f1562ac --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PricingPolicyMachineHourTierRepository.java @@ -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 { + List findAllByPricingPolicyOrderByTierStartHoursAsc(PricingPolicy pricingPolicy); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PricingPolicyRepository.java b/backend/src/main/java/com/printcalculator/repository/PricingPolicyRepository.java new file mode 100644 index 0000000..72b06d6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PricingPolicyRepository.java @@ -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 findFirstByIsActiveTrueOrderByValidFromDesc(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PrinterMachineRepository.java b/backend/src/main/java/com/printcalculator/repository/PrinterMachineRepository.java new file mode 100644 index 0000000..e2c9a9d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PrinterMachineRepository.java @@ -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 { + Optional findByPrinterDisplayName(String printerDisplayName); + Optional findFirstByIsActiveTrue(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/service/GCodeParser.java b/backend/src/main/java/com/printcalculator/service/GCodeParser.java index f8a1691..1257a38 100644 --- a/backend/src/main/java/com/printcalculator/service/GCodeParser.java +++ b/backend/src/main/java/com/printcalculator/service/GCodeParser.java @@ -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++; } } diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index ada3fe5..206ff33 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -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 notes = new ArrayList<>(); - // notes.add("Generated via Dynamic Slicer (Java Backend)"); - return new QuoteResult(totalPrice, "CHF", stats, breakdown, notes); + List 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 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"; } } diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 1594d32..573b668 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -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 machineOverrides, Map 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 { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 49c9036..fc32567 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java b/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java index 9fd6078..abbc95b 100644 --- a/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java +++ b/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java @@ -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(); } } diff --git a/db.sql b/db.sql new file mode 100644 index 0000000..f7b376b --- /dev/null +++ b/db.sql @@ -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), -- 0–10 h + (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–20 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; diff --git a/docker-compose.yml b/docker-compose.yml index 7009b84..faf3593 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..57b0d43 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -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 diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 594415a..539af35 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -48,6 +48,7 @@ @@ -80,20 +81,20 @@ @if (mode() === 'easy') { } @else { } @@ -105,13 +106,13 @@ @@ -147,7 +148,7 @@ {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }} diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 4dd76ab..3d843aa 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -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(false); uploadProgress = input(0); submitRequest = output(); + private estimator = inject(QuoteEstimatorService); + private fb = inject(FormBuilder); + form: FormGroup; items = signal([]); selectedFile = signal(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([]); + qualities = signal([]); + nozzleDiameters = signal([]); + infillPatterns = signal([]); + layerHeights = signal([]); - 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([]); + + 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); } diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index e7c8620..8c216a7 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -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 { + console.log('QuoteEstimatorService: Requesting options...'); + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.get(`${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 { - 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'); diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.html b/frontend/src/app/shared/components/color-selector/color-selector.component.html index 79df3df..079a5b6 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.html +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.html @@ -12,7 +12,7 @@ @if (isOpen()) {
- @for (category of categories; track category.name) { + @for (category of categories(); track category.name) {
{{ category.name }}
diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.ts b/frontend/src/app/shared/components/color-selector/color-selector.component.ts index cfd8d1a..7ac3185 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.ts +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.ts @@ -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('Black'); + variants = input([]); colorSelected = output(); 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()); } diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index bc16350..48dc8e5 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -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' }; -- 2.49.1 From 8fac8ac8924da2d5993cb3b8d8d195b84fe855fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 14:53:46 +0100 Subject: [PATCH 02/19] feat(back-end): db connections implemented and created users --- .gitea/workflows/cicd.yaml | 36 +++++++- Makefile | 10 --- .../printcalculator/model/CostBreakdown.java | 11 --- .../printcalculator/model/QuoteResult.java | 22 +---- .../printcalculator/service/GCodeParser.java | 89 ++++++++++++++++--- .../service/QuoteCalculator.java | 21 +---- .../service/GCodeParserTest.java | 36 ++++++++ docker-compose.deploy.yml | 5 +- .../calculator/calculator-page.component.scss | 8 +- .../upload-form/upload-form.component.ts | 5 ++ .../services/quote-estimator.service.ts | 81 +++++++++++++---- 11 files changed, 225 insertions(+), 99 deletions(-) delete mode 100644 Makefile delete mode 100644 backend/src/main/java/com/printcalculator/model/CostBreakdown.java diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 0c975d8..4ef1bdb 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -92,7 +92,7 @@ jobs: echo "ENV=dev" >> "$GITHUB_ENV" fi - - name: Trigger deploy on Unraid (forced command key) + - name: Setup SSH key shell: bash run: | set -euo pipefail @@ -120,9 +120,39 @@ jobs: # 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null - # ... (resto del codice uguale) ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + - name: Write DB env on server + shell: bash + run: | + if [[ "${{ env.ENV }}" == "prod" ]]; then + DB_URL="${{ secrets.DB_URL_PROD }}" + DB_USER="${{ secrets.DB_USERNAME_PROD }}" + DB_PASS="${{ secrets.DB_PASSWORD_PROD }}" + elif [[ "${{ env.ENV }}" == "int" ]]; then + DB_URL="${{ secrets.DB_URL_INT }}" + DB_USER="${{ secrets.DB_USERNAME_INT }}" + DB_PASS="${{ secrets.DB_PASSWORD_INT }}" + else + DB_URL="${{ secrets.DB_URL_DEV }}" + DB_USER="${{ secrets.DB_USERNAME_DEV }}" + DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" + fi + + cat > /tmp/pc.env < notes; - private double setupCost; - public QuoteResult(double totalPrice, String currency, PrintStats stats, CostBreakdown breakdown, List notes, double setupCost) { + public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) { this.totalPrice = totalPrice; this.currency = currency; this.stats = stats; - this.breakdown = breakdown; - this.notes = notes; this.setupCost = setupCost; } @@ -36,14 +24,6 @@ public class QuoteResult { public PrintStats getStats() { return stats; } - - public CostBreakdown getBreakdown() { - return breakdown; - } - - public List getNotes() { - return notes; - } public double getSetupCost() { return setupCost; diff --git a/backend/src/main/java/com/printcalculator/service/GCodeParser.java b/backend/src/main/java/com/printcalculator/service/GCodeParser.java index 1257a38..cbb912b 100644 --- a/backend/src/main/java/com/printcalculator/service/GCodeParser.java +++ b/backend/src/main/java/com/printcalculator/service/GCodeParser.java @@ -17,7 +17,15 @@ 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*(.*)", Pattern.CASE_INSENSITIVE); + private static final Pattern TOTAL_ESTIMATED_TIME_PATTERN = Pattern.compile( + ";\\s*.*total\\s+estimated\\s+time\\s*[:=]\\s*([^;]+)", + Pattern.CASE_INSENSITIVE); + private static final Pattern MODEL_PRINTING_TIME_PATTERN = Pattern.compile( + ";\\s*.*model\\s+printing\\s+time\\s*[:=]\\s*([^;]+)", + Pattern.CASE_INSENSITIVE); + private static final Pattern TIME_PATTERN = Pattern.compile( + ";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+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*(.*)"); @@ -43,6 +51,22 @@ public class GCodeParser { System.out.println("DEBUG: Found potential time line: '" + line + "'"); } + Matcher totalTimeMatcher = TOTAL_ESTIMATED_TIME_PATTERN.matcher(line); + if (totalTimeMatcher.find()) { + timeFormatted = totalTimeMatcher.group(1).trim(); + seconds = parseTimeString(timeFormatted); + System.out.println("GCodeParser: Found total estimated time: " + timeFormatted + " (" + seconds + "s)"); + continue; + } + + Matcher modelTimeMatcher = MODEL_PRINTING_TIME_PATTERN.matcher(line); + if (modelTimeMatcher.find()) { + timeFormatted = modelTimeMatcher.group(1).trim(); + seconds = parseTimeString(timeFormatted); + System.out.println("GCodeParser: Found model printing time: " + timeFormatted + " (" + seconds + "s)"); + continue; + } + Matcher timeMatcher = TIME_PATTERN.matcher(line); if (timeMatcher.find()) { timeFormatted = timeMatcher.group(1).trim(); @@ -72,21 +96,60 @@ public class GCodeParser { } private long parseTimeString(String timeStr) { - // Formats: "1d 2h 3m 4s" or "1h 20m 10s" - long totalSeconds = 0; - - Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr); - if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400; + // Formats: "1d 2h 3m 4s", "1h 20m 10s", "01:23:45", "12:34" + String lower = timeStr.toLowerCase(); + double totalSeconds = 0; + boolean matched = false; - Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr); - if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600; + Matcher d = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*d").matcher(lower); + if (d.find()) { + totalSeconds += Double.parseDouble(d.group(1)) * 86400; + matched = true; + } - Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr); - if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60; + Matcher h = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*h").matcher(lower); + if (h.find()) { + totalSeconds += Double.parseDouble(h.group(1)) * 3600; + matched = true; + } - Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr); - if (s.find()) totalSeconds += Long.parseLong(s.group(1)); + Matcher m = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*m").matcher(lower); + if (m.find()) { + totalSeconds += Double.parseDouble(m.group(1)) * 60; + matched = true; + } - return totalSeconds; + Matcher s = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*s").matcher(lower); + if (s.find()) { + totalSeconds += Double.parseDouble(s.group(1)); + matched = true; + } + + if (matched) { + return Math.round(totalSeconds); + } + + long daySeconds = 0; + Matcher dayPrefix = Pattern.compile("(\\d+)\\s*d").matcher(lower); + if (dayPrefix.find()) { + daySeconds = Long.parseLong(dayPrefix.group(1)) * 86400; + } + + Matcher hms = Pattern.compile("(\\d{1,2}):(\\d{2}):(\\d{2})").matcher(lower); + if (hms.find()) { + long hours = Long.parseLong(hms.group(1)); + long minutes = Long.parseLong(hms.group(2)); + long seconds = Long.parseLong(hms.group(3)); + return daySeconds + hours * 3600 + minutes * 60 + seconds; + } + + Matcher ms = Pattern.compile("(\\d{1,2}):(\\d{2})").matcher(lower); + if (ms.find()) { + long minutes = Long.parseLong(ms.group(1)); + long seconds = Long.parseLong(ms.group(2)); + return daySeconds + minutes * 60 + seconds; + } + + return 0; } } diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index 206ff33..432c62f 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -6,7 +6,6 @@ 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; @@ -18,10 +17,7 @@ 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 { @@ -101,22 +97,7 @@ public class QuoteCalculator { 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) - ); - - List 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()); + return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue()); } private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) { diff --git a/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java b/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java index abbc95b..04055b5 100644 --- a/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java +++ b/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java @@ -74,4 +74,40 @@ class GCodeParserTest { tempFile.delete(); } + + @Test + void parse_colonFormattedTime_returnsCorrectStats() throws IOException { + File tempFile = File.createTempFile("test_colon", ".gcode"); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("; generated by OrcaSlicer\n"); + writer.write("; print time: 01:02:03\n"); + writer.write("; filament used [g] = 7.5\n"); + } + + GCodeParser parser = new GCodeParser(); + PrintStats stats = parser.parse(tempFile); + + assertEquals(3723L, stats.printTimeSeconds()); + assertEquals("01:02:03", stats.printTimeFormatted()); + + tempFile.delete(); + } + + @Test + void parse_totalEstimatedTimeInline_returnsCorrectStats() throws IOException { + File tempFile = File.createTempFile("test_total", ".gcode"); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("; generated by OrcaSlicer\n"); + writer.write("; model printing time: 5m 17s; total estimated time: 5m 21s\n"); + writer.write("; filament used [g] = 2.0\n"); + } + + GCodeParser parser = new GCodeParser(); + PrintStats stats = parser.parse(tempFile); + + assertEquals(321L, stats.printTimeSeconds()); + assertEquals("5m 21s", stats.printTimeFormatted()); + + tempFile.delete(); + } } diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 94bdfb2..9f0837a 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -13,6 +13,9 @@ services: - ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH} - PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS} - MARKUP_PERCENT=${MARKUP_PERCENT} + - DB_URL=${DB_URL} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always @@ -31,4 +34,4 @@ services: volumes: backend_profiles_prod: backend_profiles_int: - backend_profiles_dev: \ No newline at end of file + backend_profiles_dev: diff --git a/frontend/src/app/features/calculator/calculator-page.component.scss b/frontend/src/app/features/calculator/calculator-page.component.scss index 02cdec0..f118857 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.scss +++ b/frontend/src/app/features/calculator/calculator-page.component.scss @@ -26,11 +26,11 @@ min-width: 0; display: flex; flex-direction: column; +} - /* Make children (specifically app-card) stretch */ - > * { - flex: 1; - } +/* Stretch only the loading card so the spinner stays centered */ +.col-result > .loading-state { + flex: 1; } /* Mode Selector (Segmented Control style) */ diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 3d843aa..726a4a5 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -191,6 +191,11 @@ export class UploadFormComponent implements OnInit { const item = this.items().find(i => i.file === file); if (item) { + const vars = this.currentMaterialVariants(); + if (vars && vars.length > 0) { + const found = vars.find(v => v.colorName === item.color); + if (found) return found.hexColor; + } return getColorHex(item.color); } return '#facf0a'; diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 8c216a7..c251b45 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -49,6 +49,18 @@ interface BackendResponse { error?: string; } +interface BackendQuoteResult { + totalPrice: number; + currency: string; + setupCost: number; + stats: { + printTimeSeconds: number; + printTimeFormatted: string; + filamentWeightGrams: number; + filamentLengthMm: number; + }; +} + // Options Interfaces export interface MaterialOption { code: string; @@ -159,7 +171,7 @@ export class QuoteEstimatorService { // @ts-ignore if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - return this.http.post(`${environment.apiUrl}/api/quote`, formData, { + return this.http.post(`${environment.apiUrl}/api/quote`, formData, { headers, reportProgress: true, observe: 'events' @@ -206,7 +218,9 @@ export class QuoteEstimatorService { // Calculate Results let setupCost = 10; - + let setupCostFromBackend: number | null = null; + let currencyFromBackend: string | null = null; + if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) { setupCost += 2; } @@ -214,18 +228,27 @@ export class QuoteEstimatorService { const items: QuoteItem[] = []; finalResponses.forEach((res, idx) => { - if (res && res.success) { - const originalItem = request.items[idx]; - items.push({ - fileName: res.fileName, - unitPrice: res.data.cost.total, - unitTime: res.data.print_time_seconds, - unitWeight: res.data.material_grams, - quantity: res.originalQty, // Use the requested quantity - material: request.material, - color: originalItem.color || 'Default' - }); + if (!res) return; + const originalItem = request.items[idx]; + const normalized = this.normalizeResponse(res); + if (!normalized.success) return; + + if (normalized.currency && currencyFromBackend == null) { + currencyFromBackend = normalized.currency; } + if (normalized.setupCost != null && setupCostFromBackend == null) { + setupCostFromBackend = normalized.setupCost; + } + + items.push({ + fileName: res.fileName, + unitPrice: normalized.unitPrice, + unitTime: normalized.unitTime, + unitWeight: normalized.unitWeight, + quantity: res.originalQty, // Use the requested quantity + material: request.material, + color: originalItem.color || 'Default' + }); }); if (items.length === 0) { @@ -234,7 +257,8 @@ export class QuoteEstimatorService { } // Initial Aggregation - let grandTotal = setupCost; + const useBackendSetup = setupCostFromBackend != null; + let grandTotal = useBackendSetup ? 0 : setupCost; let totalTime = 0; let totalWeight = 0; @@ -249,8 +273,8 @@ export class QuoteEstimatorService { const result: QuoteResult = { items, - setupCost, - currency: 'CHF', + setupCost: useBackendSetup ? setupCostFromBackend! : setupCost, + currency: currencyFromBackend || 'CHF', totalPrice: Math.round(grandTotal * 100) / 100, totalTimeHours: totalHours, totalTimeMinutes: totalMinutes, @@ -274,6 +298,31 @@ export class QuoteEstimatorService { }); } + private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } { + if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') { + return { + success: true, + unitPrice: res.totalPrice, + unitTime: res.stats.printTimeSeconds, + unitWeight: res.stats.filamentWeightGrams, + setupCost: res.setupCost, + currency: res.currency + }; + } + + if (res && res.success && res.data) { + return { + success: true, + unitPrice: res.data.cost.total, + unitTime: res.data.print_time_seconds, + unitWeight: res.data.material_grams, + currency: 'CHF' + }; + } + + return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 }; + } + private mapMaterial(mat: string): string { const m = mat.toUpperCase(); if (m.includes('PLA')) return 'pla_basic'; -- 2.49.1 From 7b92e63a4995427f0a6b48aa8d81604d414d9105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:03:07 +0100 Subject: [PATCH 03/19] fix(deploy): fix cicd.yaml printf fixed --- .gitea/workflows/cicd.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 4ef1bdb..9895f00 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -139,11 +139,8 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - cat > /tmp/pc.env < /tmp/pc.env ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ "setenv ${{ env.ENV }}" < /tmp/pc.env -- 2.49.1 From dde92af85767eee78c80ffa931ad7df0959e8801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:08:25 +0100 Subject: [PATCH 04/19] fix(deploy): fix regex --- .../src/main/java/com/printcalculator/service/GCodeParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/printcalculator/service/GCodeParser.java b/backend/src/main/java/com/printcalculator/service/GCodeParser.java index cbb912b..ea414d0 100644 --- a/backend/src/main/java/com/printcalculator/service/GCodeParser.java +++ b/backend/src/main/java/com/printcalculator/service/GCodeParser.java @@ -24,7 +24,7 @@ public class GCodeParser { ";\\s*.*model\\s+printing\\s+time\\s*[:=]\\s*([^;]+)", Pattern.CASE_INSENSITIVE); private static final Pattern TIME_PATTERN = Pattern.compile( - ";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*[:=]\\s*(.*)", + ";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+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*(.*)"); -- 2.49.1 From dfc27da1428d989b257a0671aee0a54af4ed5f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:10:03 +0100 Subject: [PATCH 05/19] feat(deploy): added cache Gradle step for faster deploy --- .gitea/workflows/cicd.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 9895f00..f6216b3 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -21,6 +21,16 @@ jobs: java-version: '21' distribution: 'temurin' + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + - name: Run Tests with Gradle run: | cd backend -- 2.49.1 From b249cf2000126cedc012f86af6f51bfac8755741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:21:41 +0100 Subject: [PATCH 06/19] feat(deploy): fix deploy ports --- .gitea/workflows/cicd.yaml | 18 ++++++++++++++---- deploy/envs/dev.env | 2 +- deploy/envs/int.env | 2 +- deploy/envs/prod.env | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index f6216b3..f764cb7 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -132,9 +132,13 @@ jobs: ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null - - name: Write DB env on server + - name: Write env to server shell: bash run: | + # 1. Start with the static env file content + cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env + + # 2. Determine DB credentials if [[ "${{ env.ENV }}" == "prod" ]]; then DB_URL="${{ secrets.DB_URL_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}" @@ -149,11 +153,17 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - printf 'DB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ - "$DB_URL" "$DB_USER" "$DB_PASS" > /tmp/pc.env + # 3. Append DB credentials + printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ + "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env + # 4. Debug: print content (for debug purposes) + echo "Preparing to send env file with variables:" + grep -v "PASSWORD" /tmp/full_env.env || true + + # 5. Send to server ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ - "setenv ${{ env.ENV }}" < /tmp/pc.env + "setenv ${{ env.ENV }}" < /tmp/full_env.env - name: Trigger deploy on Unraid (forced command key) shell: bash diff --git a/deploy/envs/dev.env b/deploy/envs/dev.env index b9d6a9b..882f235 100644 --- a/deploy/envs/dev.env +++ b/deploy/envs/dev.env @@ -1,5 +1,5 @@ REGISTRY_URL=git.joekung.ch -REPO_OWNER=JoeKung +REPO_OWNER=joekung ENV=dev TAG=dev diff --git a/deploy/envs/int.env b/deploy/envs/int.env index ccb53a7..a992134 100644 --- a/deploy/envs/int.env +++ b/deploy/envs/int.env @@ -1,5 +1,5 @@ REGISTRY_URL=git.joekung.ch -REPO_OWNER=JoeKung +REPO_OWNER=joekung ENV=int TAG=int diff --git a/deploy/envs/prod.env b/deploy/envs/prod.env index 41c49dd..644d444 100644 --- a/deploy/envs/prod.env +++ b/deploy/envs/prod.env @@ -1,5 +1,5 @@ REGISTRY_URL=git.joekung.ch -REPO_OWNER=JoeKung +REPO_OWNER=joekung ENV=prod TAG=prod -- 2.49.1 From b7d81040e615ac79f39a270589204202e0f36e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:27:09 +0100 Subject: [PATCH 07/19] feat(deploy): fix deploy ports --- .gitea/workflows/cicd.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index f764cb7..027b131 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -91,6 +91,9 @@ jobs: needs: build-and-push runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set ENV shell: bash run: | -- 2.49.1 From 1583ff479c27b9f387404198f667a1b54386a484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:46:06 +0100 Subject: [PATCH 08/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 9f0837a..8eccc3a 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -13,9 +13,9 @@ services: - ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH} - PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS} - MARKUP_PERCENT=${MARKUP_PERCENT} - - DB_URL=${DB_URL} - - DB_USERNAME=${DB_USERNAME} - - DB_PASSWORD=${DB_PASSWORD} + - SPRING_DATASOURCE_URL=${DB_URL} + - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always -- 2.49.1 From 5620f6a8eb9534d51b4d92d4dbbfd67ced0fb737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:50:09 +0100 Subject: [PATCH 09/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 8eccc3a..97d2224 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -21,6 +21,7 @@ services: restart: always volumes: - backend_profiles_${ENV}:/app/profiles + command: ["java", "-jar", "app.jar", "--spring.datasource.url=${DB_URL}", "--spring.datasource.username=${DB_USERNAME}", "--spring.datasource.password=${DB_PASSWORD}"] frontend: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} -- 2.49.1 From 3ca3f8e466de9e4b142de49ff40223a69eba78f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 15:54:06 +0100 Subject: [PATCH 10/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 97d2224..46dd46a 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -21,7 +21,7 @@ services: restart: always volumes: - backend_profiles_${ENV}:/app/profiles - command: ["java", "-jar", "app.jar", "--spring.datasource.url=${DB_URL}", "--spring.datasource.username=${DB_USERNAME}", "--spring.datasource.password=${DB_PASSWORD}"] + command: ["sh", "-c", "echo 'DB_URL IS: $DB_URL'; java -jar app.jar --spring.datasource.url=${DB_URL} --spring.datasource.username=${DB_USERNAME} --spring.datasource.password=${DB_PASSWORD}"] frontend: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} -- 2.49.1 From 5ba203a8d1acb30038b8b8c7287da93198a54a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 16:04:06 +0100 Subject: [PATCH 11/19] feat(deploy): compose deploy fixed --- deploy/envs/dev.env | 7 +------ deploy/envs/int.env | 7 +------ deploy/envs/prod.env | 7 +------ docker-compose.deploy.yml | 6 +----- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/deploy/envs/dev.env b/deploy/envs/dev.env index 882f235..0364437 100644 --- a/deploy/envs/dev.env +++ b/deploy/envs/dev.env @@ -7,9 +7,4 @@ TAG=dev BACKEND_PORT=18002 FRONTEND_PORT=18082 -# Application Config -FILAMENT_COST_PER_KG=22.0 -MACHINE_COST_PER_HOUR=2.50 -ENERGY_COST_PER_KWH=0.30 -PRINTER_POWER_WATTS=150 -MARKUP_PERCENT=20 + diff --git a/deploy/envs/int.env b/deploy/envs/int.env index a992134..79f7a35 100644 --- a/deploy/envs/int.env +++ b/deploy/envs/int.env @@ -7,9 +7,4 @@ TAG=int BACKEND_PORT=18001 FRONTEND_PORT=18081 -# Application Config -FILAMENT_COST_PER_KG=22.0 -MACHINE_COST_PER_HOUR=2.50 -ENERGY_COST_PER_KWH=0.30 -PRINTER_POWER_WATTS=150 -MARKUP_PERCENT=20 + diff --git a/deploy/envs/prod.env b/deploy/envs/prod.env index 644d444..878558b 100644 --- a/deploy/envs/prod.env +++ b/deploy/envs/prod.env @@ -7,9 +7,4 @@ TAG=prod BACKEND_PORT=8000 FRONTEND_PORT=80 -# Application Config -FILAMENT_COST_PER_KG=22.0 -MACHINE_COST_PER_HOUR=2.50 -ENERGY_COST_PER_KWH=0.30 -PRINTER_POWER_WATTS=150 -MARKUP_PERCENT=20 + diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 46dd46a..f69fde2 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -8,11 +8,7 @@ services: ports: - "${BACKEND_PORT}:8000" environment: - - FILAMENT_COST_PER_KG=${FILAMENT_COST_PER_KG} - - MACHINE_COST_PER_HOUR=${MACHINE_COST_PER_HOUR} - - ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH} - - PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS} - - MARKUP_PERCENT=${MARKUP_PERCENT} + - SPRING_DATASOURCE_URL=${DB_URL} - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} -- 2.49.1 From ab5f6a609dfed27af7e62251678f7d4d26506bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 16:14:00 +0100 Subject: [PATCH 12/19] feat(deploy): compose deploy fixed --- backend/Dockerfile | 4 +++- backend/entrypoint.sh | 13 +++++++++++++ docker-compose.deploy.yml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 backend/entrypoint.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 32b5ac1..fc44418 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -41,4 +41,6 @@ COPY profiles ./profiles EXPOSE 8080 -CMD ["java", "-jar", "app.jar"] +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..17d5cf5 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +echo "----------------------------------------------------------------" +echo "Starting Backend Application" +echo "DB_URL: $DB_URL" +echo "DB_USERNAME: $DB_USERNAME" +echo "SLICER_PATH: $SLICER_PATH" +echo "----------------------------------------------------------------" + +# Exec java with explicit properties from env +exec java -jar app.jar \ + --spring.datasource.url="${DB_URL}" \ + --spring.datasource.username="${DB_USERNAME}" \ + --spring.datasource.password="${DB_PASSWORD}" diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index f69fde2..50bfa15 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -17,7 +17,7 @@ services: restart: always volumes: - backend_profiles_${ENV}:/app/profiles - command: ["sh", "-c", "echo 'DB_URL IS: $DB_URL'; java -jar app.jar --spring.datasource.url=${DB_URL} --spring.datasource.username=${DB_USERNAME} --spring.datasource.password=${DB_PASSWORD}"] + frontend: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} -- 2.49.1 From d20d12c1f42b928d00c9fa44080f2c3812597ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 16:19:31 +0100 Subject: [PATCH 13/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 50bfa15..479070d 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -8,10 +8,9 @@ services: ports: - "${BACKEND_PORT}:8000" environment: - - - SPRING_DATASOURCE_URL=${DB_URL} - - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} - - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} + - DB_URL=${DB_URL} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always -- 2.49.1 From 85b823d6146208a0b21680af1a0ccd8e7f2ab6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Feb 2026 16:25:20 +0100 Subject: [PATCH 14/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 479070d..faaa4ca 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -7,6 +7,8 @@ services: container_name: print-calculator-backend-${ENV} ports: - "${BACKEND_PORT}:8000" + env_file: + - .env environment: - DB_URL=${DB_URL} - DB_USERNAME=${DB_USERNAME} -- 2.49.1 From 3da3e6c60cf447f9ec37ea7c45549f9b33bec568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 11:56:31 +0100 Subject: [PATCH 15/19] feat(deploy): compose deploy fixed --- .gitea/workflows/cicd.yaml | 2 ++ backend/entrypoint.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 027b131..386d3cd 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -167,6 +167,8 @@ jobs: # 5. Send to server ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ "setenv ${{ env.ENV }}" < /tmp/full_env.env + + - name: Trigger deploy on Unraid (forced command key) shell: bash diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 17d5cf5..1dcaaa2 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -4,6 +4,8 @@ echo "Starting Backend Application" echo "DB_URL: $DB_URL" echo "DB_USERNAME: $DB_USERNAME" echo "SLICER_PATH: $SLICER_PATH" +echo "--- ALL ENV VARS ---" +env echo "----------------------------------------------------------------" # Exec java with explicit properties from env -- 2.49.1 From 3e9745c7ccf927822068b630d5ad43d4ad3bf963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 12:07:16 +0100 Subject: [PATCH 16/19] feat(deploy): compose deploy fixed --- docker-compose.deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index faaa4ca..5adbe15 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -24,7 +24,7 @@ services: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} container_name: print-calculator-frontend-${ENV} ports: - - "${FRONTEND_PORT}:8008" + - "${FRONTEND_PORT}:80" depends_on: - backend restart: always -- 2.49.1 From e17da96c2297d486f0f667a20d319b06855e1960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 12:19:59 +0100 Subject: [PATCH 17/19] fix(web): enrivoments --- frontend/src/environments/environment.prod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index 008dae6..e1801b7 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,5 +1,5 @@ export const environment = { production: true, - apiUrl: 'https://3d-fab.ch', + apiUrl: '', basicAuth: '' }; -- 2.49.1 From 7ebaff322cac8d957c1b4619e42c0eb483fdb460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 12:44:12 +0100 Subject: [PATCH 18/19] feat(web): improvements in routes for calculator --- frontend/src/app/app.routes.ts | 2 +- .../src/app/core/layout/navbar.component.html | 2 +- .../calculator/calculator-page.component.ts | 20 +++++++++++++++---- .../features/calculator/calculator.routes.ts | 4 +++- .../src/app/features/home/home.component.html | 4 ++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 9d013b7..183f658 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -10,7 +10,7 @@ export const routes: Routes = [ loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent) }, { - path: 'cal', + path: 'calculator', loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES) }, { diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 5c1fd28..189ff6f 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -10,7 +10,7 @@
+ @if (result().notes) { +
+ +

{{ result().notes }}

+
+ } +
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 0c16042..c9f6973 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -83,3 +83,27 @@ } .actions { display: flex; flex-direction: column; gap: var(--space-3); } + +.notes-section { + margin-top: var(--space-4); + margin-bottom: var(--space-4); + padding: var(--space-3); + background: var(--color-neutral-50); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + + label { + font-weight: 500; + font-size: 0.9rem; + color: var(--color-text-muted); + display: block; + margin-bottom: var(--space-2); + } + + p { + margin: 0; + font-size: 0.95rem; + color: var(--color-text); + white-space: pre-wrap; /* Preserve line breaks */ + } +} diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 539af35..eba5371 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -129,13 +129,14 @@
- } + +
@if (loading() && uploadProgress() < 100) { diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index c251b45..fccb3ac 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -35,6 +35,7 @@ export interface QuoteResult { totalTimeHours: number; totalTimeMinutes: number; totalWeight: number; + notes?: string; } interface BackendResponse { @@ -278,7 +279,8 @@ export class QuoteEstimatorService { totalPrice: Math.round(grandTotal * 100) / 100, totalTimeHours: totalHours, totalTimeMinutes: totalMinutes, - totalWeight: Math.ceil(totalWeight) + totalWeight: Math.ceil(totalWeight), + notes: request.notes }; observer.next(result); -- 2.49.1