diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 0c975d8..386d3cd 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 @@ -81,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: | @@ -92,7 +105,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 +133,48 @@ 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 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 }}" + 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 + + # 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/full_env.env + + + + - name: Trigger deploy on Unraid (forced command key) + shell: bash + run: | + set -euo pipefail + # Aggiungiamo le opzioni di verbosità se dovesse fallire ancora, # e assicuriamoci che l'input sia pulito - ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "${{ env.ENV }}" \ No newline at end of file + ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}" diff --git a/Makefile b/Makefile deleted file mode 100644 index 0eeb77a..0000000 --- a/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -.PHONY: install s -install: - @echo "Installing Backend dependencies..." - cd backend && pip install -r requirements.txt || pip install fastapi uvicorn trimesh python-multipart numpy - @echo "Installing Frontend dependencies..." - cd frontend && npm install - -start: - @echo "Starting development environment..." - ./start.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..1dcaaa2 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +echo "----------------------------------------------------------------" +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 +exec java -jar app.jar \ + --spring.datasource.url="${DB_URL}" \ + --spring.datasource.username="${DB_USERNAME}" \ + --spring.datasource.password="${DB_PASSWORD}" 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 deleted file mode 100644 index 64eab4f..0000000 --- a/backend/src/main/java/com/printcalculator/model/CostBreakdown.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.printcalculator.model; - -import java.math.BigDecimal; - -public record CostBreakdown( - BigDecimal materialCost, - BigDecimal machineCost, - BigDecimal energyCost, - BigDecimal subtotal, - BigDecimal markupAmount -) {} diff --git a/backend/src/main/java/com/printcalculator/model/QuoteResult.java b/backend/src/main/java/com/printcalculator/model/QuoteResult.java index 22c242b..df155d4 100644 --- a/backend/src/main/java/com/printcalculator/model/QuoteResult.java +++ b/backend/src/main/java/com/printcalculator/model/QuoteResult.java @@ -1,12 +1,31 @@ package com.printcalculator.model; -import java.math.BigDecimal; -import java.util.List; +public class QuoteResult { + private double totalPrice; + private String currency; + private PrintStats stats; + private double setupCost; -public record QuoteResult( - BigDecimal totalPrice, - String currency, - PrintStats stats, - CostBreakdown breakdown, - List notes -) {} + public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) { + this.totalPrice = totalPrice; + this.currency = currency; + this.stats = stats; + this.setupCost = setupCost; + } + + public double getTotalPrice() { + return totalPrice; + } + + public String getCurrency() { + return currency; + } + + public PrintStats getStats() { + return stats; + } + + 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..ea414d0 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*=\\s*(.*)"); + 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*(.*)"); @@ -29,12 +37,33 @@ 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 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; } @@ -42,12 +71,14 @@ public class GCodeParser { 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 +86,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++; } } @@ -65,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 ada3fe5..432c62f 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -1,59 +1,158 @@ package com.printcalculator.service; -import com.printcalculator.config.AppProperties; -import com.printcalculator.model.CostBreakdown; + +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.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.List; @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); + return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue()); + } - 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) - ); + 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())); + } + } - List notes = new ArrayList<>(); - // notes.add("Generated via Dynamic Slicer (Java Backend)"); + return totalCost; + } - return new QuoteResult(totalPrice, "CHF", stats, breakdown, notes); + 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..04055b5 100644 --- a/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java +++ b/backend/src/test/java/com/printcalculator/service/GCodeParserTest.java @@ -52,6 +52,62 @@ 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(); + } + + @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/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/deploy/envs/dev.env b/deploy/envs/dev.env index b9d6a9b..0364437 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 @@ -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 ccb53a7..79f7a35 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 @@ -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 41c49dd..878558b 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 @@ -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 94bdfb2..5adbe15 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -7,23 +7,24 @@ services: container_name: print-calculator-backend-${ENV} ports: - "${BACKEND_PORT}:8000" + env_file: + - .env 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} + - DB_URL=${DB_URL} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always volumes: - backend_profiles_${ENV}:/app/profiles + frontend: 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 @@ -31,4 +32,4 @@ services: volumes: backend_profiles_prod: backend_profiles_int: - backend_profiles_dev: \ No newline at end of file + backend_profiles_dev: 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/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 @@