dev #7
@@ -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 }}"
|
||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"
|
||||
|
||||
10
Makefile
10
Makefile
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
15
backend/entrypoint.sh
Normal file
15
backend/entrypoint.sh
Normal file
@@ -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}"
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.OptionsResponse;
|
||||
import com.printcalculator.entity.FilamentMaterialType;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import com.printcalculator.entity.*; // This line replaces specific entity imports
|
||||
import com.printcalculator.repository.FilamentMaterialTypeRepository;
|
||||
import com.printcalculator.repository.FilamentVariantRepository;
|
||||
import com.printcalculator.repository.LayerHeightOptionRepository;
|
||||
import com.printcalculator.repository.NozzleOptionRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
public class OptionsController {
|
||||
|
||||
private final FilamentMaterialTypeRepository materialRepo;
|
||||
private final FilamentVariantRepository variantRepo;
|
||||
private final LayerHeightOptionRepository layerHeightRepo;
|
||||
private final NozzleOptionRepository nozzleRepo;
|
||||
|
||||
public OptionsController(FilamentMaterialTypeRepository materialRepo,
|
||||
FilamentVariantRepository variantRepo,
|
||||
LayerHeightOptionRepository layerHeightRepo,
|
||||
NozzleOptionRepository nozzleRepo) {
|
||||
this.materialRepo = materialRepo;
|
||||
this.variantRepo = variantRepo;
|
||||
this.layerHeightRepo = layerHeightRepo;
|
||||
this.nozzleRepo = nozzleRepo;
|
||||
}
|
||||
|
||||
@GetMapping("/api/calculator/options")
|
||||
public ResponseEntity<OptionsResponse> getOptions() {
|
||||
// 1. Materials & Variants
|
||||
List<FilamentMaterialType> types = materialRepo.findAll();
|
||||
List<FilamentVariant> allVariants = variantRepo.findAll();
|
||||
|
||||
List<OptionsResponse.MaterialOption> materialOptions = types.stream()
|
||||
.map(type -> {
|
||||
List<OptionsResponse.VariantOption> variants = allVariants.stream()
|
||||
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
|
||||
.map(v -> new OptionsResponse.VariantOption(
|
||||
v.getVariantDisplayName(),
|
||||
v.getColorName(),
|
||||
getColorHex(v.getColorName()), // Need helper or store hex in DB
|
||||
v.getStockSpools().doubleValue() <= 0
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Only include material if it has active variants
|
||||
if (variants.isEmpty()) return null;
|
||||
|
||||
return new OptionsResponse.MaterialOption(
|
||||
type.getMaterialCode(),
|
||||
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
|
||||
variants
|
||||
);
|
||||
})
|
||||
.filter(m -> m != null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 2. Qualities (Static as per user request)
|
||||
List<OptionsResponse.QualityOption> qualities = List.of(
|
||||
new OptionsResponse.QualityOption("draft", "Draft"),
|
||||
new OptionsResponse.QualityOption("standard", "Standard"),
|
||||
new OptionsResponse.QualityOption("extra_fine", "High Definition")
|
||||
);
|
||||
|
||||
// 3. Infill Patterns (Static as per user request)
|
||||
List<OptionsResponse.InfillPatternOption> patterns = List.of(
|
||||
new OptionsResponse.InfillPatternOption("grid", "Grid"),
|
||||
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
|
||||
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
|
||||
);
|
||||
|
||||
// 4. Layer Heights
|
||||
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
|
||||
.filter(l -> l.getIsActive())
|
||||
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
|
||||
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
|
||||
l.getLayerHeightMm().doubleValue(),
|
||||
String.format("%.2f mm", l.getLayerHeightMm())
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 5. Nozzles
|
||||
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
|
||||
.filter(n -> n.getIsActive())
|
||||
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
|
||||
.map(n -> new OptionsResponse.NozzleOptionDTO(
|
||||
n.getNozzleDiameterMm().doubleValue(),
|
||||
String.format("%.1f mm%s", n.getNozzleDiameterMm(),
|
||||
n.getExtraNozzleChangeFeeChf().doubleValue() > 0
|
||||
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
|
||||
: " (Standard)")
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
|
||||
}
|
||||
|
||||
// Temporary helper until we add hex to DB
|
||||
private String getColorHex(String colorName) {
|
||||
String lower = colorName.toLowerCase();
|
||||
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
|
||||
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
|
||||
if (lower.contains("blue") || lower.contains("blu")) return "#1976d2";
|
||||
if (lower.contains("red") || lower.contains("rosso")) return "#d32f2f";
|
||||
if (lower.contains("green") || lower.contains("verde")) return "#388e3c";
|
||||
if (lower.contains("orange") || lower.contains("arancione")) return "#ffa726";
|
||||
if (lower.contains("grey") || lower.contains("gray") || lower.contains("grigio")) {
|
||||
if (lower.contains("dark") || lower.contains("scuro")) return "#424242";
|
||||
return "#bdbdbd";
|
||||
}
|
||||
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
|
||||
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
|
||||
return "#9e9e9e"; // Default grey
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -17,28 +21,33 @@ public class QuoteController {
|
||||
|
||||
private final SlicerService slicerService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
|
||||
// Defaults (using aliases defined in ProfileManager)
|
||||
private static final String DEFAULT_MACHINE = "bambu_a1";
|
||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||
private static final String DEFAULT_PROCESS = "standard";
|
||||
|
||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
|
||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
||||
this.slicerService = slicerService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
}
|
||||
|
||||
@PostMapping("/api/quote")
|
||||
public ResponseEntity<QuoteResult> calculateQuote(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "machine", required = false, defaultValue = DEFAULT_MACHINE) String machine,
|
||||
@RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament,
|
||||
@RequestParam(value = "process", required = false) String process,
|
||||
@RequestParam(value = "quality", required = false) String quality
|
||||
@RequestParam(value = "quality", required = false) String quality,
|
||||
// Advanced Options
|
||||
@RequestParam(value = "infill_density", required = false) Integer infillDensity,
|
||||
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
|
||||
@RequestParam(value = "layer_height", required = false) Double layerHeight,
|
||||
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
|
||||
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
|
||||
) throws IOException {
|
||||
|
||||
// Frontend sends 'quality', backend expects 'process'.
|
||||
// If process is missing, try quality. If both missing, use default.
|
||||
// ... process selection logic ...
|
||||
String actualProcess = process;
|
||||
if (actualProcess == null || actualProcess.isEmpty()) {
|
||||
if (quality != null && !quality.isEmpty()) {
|
||||
@@ -48,7 +57,31 @@ public class QuoteController {
|
||||
}
|
||||
}
|
||||
|
||||
return processRequest(file, machine, filament, actualProcess);
|
||||
// Prepare Overrides
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
Map<String, String> machineOverrides = new HashMap<>();
|
||||
|
||||
if (infillDensity != null) {
|
||||
processOverrides.put("sparse_infill_density", infillDensity + "%");
|
||||
}
|
||||
if (infillPattern != null && !infillPattern.isEmpty()) {
|
||||
processOverrides.put("sparse_infill_pattern", infillPattern);
|
||||
}
|
||||
if (layerHeight != null) {
|
||||
processOverrides.put("layer_height", String.valueOf(layerHeight));
|
||||
}
|
||||
if (supportEnabled != null) {
|
||||
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
|
||||
}
|
||||
|
||||
if (nozzleDiameter != null) {
|
||||
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter));
|
||||
// Also need to ensure the printer profile is compatible or just override?
|
||||
// Usually nozzle diameter changes require a different printer profile or deep overrides.
|
||||
// For now, we trust the override key works on the base profile.
|
||||
}
|
||||
|
||||
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
|
||||
}
|
||||
|
||||
@PostMapping("/calculate/stl")
|
||||
@@ -56,30 +89,37 @@ public class QuoteController {
|
||||
@RequestParam("file") MultipartFile file
|
||||
) throws IOException {
|
||||
// Legacy endpoint uses defaults
|
||||
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
|
||||
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
|
||||
}
|
||||
|
||||
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
|
||||
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
|
||||
Map<String, String> machineOverrides,
|
||||
Map<String, String> processOverrides) throws IOException {
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
// Fetch Default Active Machine
|
||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||
.orElseThrow(() -> new IOException("No active printer found in database"));
|
||||
|
||||
// Save uploaded file temporarily
|
||||
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||
try {
|
||||
file.transferTo(tempInput.toFile());
|
||||
|
||||
// Slice
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
|
||||
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
|
||||
|
||||
// Calculate Quote
|
||||
QuoteResult result = quoteCalculator.calculate(stats);
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record OptionsResponse(
|
||||
List<MaterialOption> materials,
|
||||
List<QualityOption> qualities,
|
||||
List<InfillPatternOption> infillPatterns,
|
||||
List<LayerHeightOptionDTO> layerHeights,
|
||||
List<NozzleOptionDTO> nozzleDiameters
|
||||
) {
|
||||
public record MaterialOption(String code, String label, List<VariantOption> variants) {}
|
||||
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
|
||||
public record QualityOption(String id, String label) {}
|
||||
public record InfillPatternOption(String id, String label) {}
|
||||
public record LayerHeightOptionDTO(double value, String label) {}
|
||||
public record NozzleOptionDTO(double value, String label) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.FilamentMaterialType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FilamentMaterialTypeRepository extends JpaRepository<FilamentMaterialType, Long> {
|
||||
Optional<FilamentMaterialType> findByMaterialCode(String materialCode);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.printcalculator.entity.FilamentMaterialType;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
|
||||
// We try to match by color name if possible, or get first active
|
||||
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
|
||||
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.InfillPattern;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InfillPatternRepository extends JpaRepository<InfillPattern, Long> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.LayerHeightOption;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LayerHeightOptionRepository extends JpaRepository<LayerHeightOption, Long> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.LayerHeightProfile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LayerHeightProfileRepository extends JpaRepository<LayerHeightProfile, Long> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.NozzleOption;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.PricingPolicyMachineHourTier;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.printcalculator.entity.PricingPolicy;
|
||||
import java.util.List;
|
||||
|
||||
public interface PricingPolicyMachineHourTierRepository extends JpaRepository<PricingPolicyMachineHourTier, Long> {
|
||||
List<PricingPolicyMachineHourTier> findAllByPricingPolicyOrderByTierStartHoursAsc(PricingPolicy pricingPolicy);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.PricingPolicy;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
|
||||
PricingPolicy findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.printcalculator.repository;
|
||||
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PrinterMachineRepository extends JpaRepository<PrinterMachine, Long> {
|
||||
Optional<PrinterMachine> findByPrinterDisplayName(String printerDisplayName);
|
||||
Optional<PrinterMachine> findFirstByIsActiveTrue();
|
||||
}
|
||||
@@ -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;
|
||||
// Formats: "1d 2h 3m 4s", "1h 20m 10s", "01:23:45", "12:34"
|
||||
String lower = timeStr.toLowerCase();
|
||||
double totalSeconds = 0;
|
||||
boolean matched = false;
|
||||
|
||||
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
|
||||
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
|
||||
Matcher d = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*d").matcher(lower);
|
||||
if (d.find()) {
|
||||
totalSeconds += Double.parseDouble(d.group(1)) * 86400;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
|
||||
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
|
||||
Matcher h = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*h").matcher(lower);
|
||||
if (h.find()) {
|
||||
totalSeconds += Double.parseDouble(h.group(1)) * 3600;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
|
||||
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
|
||||
Matcher m = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*m").matcher(lower);
|
||||
if (m.find()) {
|
||||
totalSeconds += Double.parseDouble(m.group(1)) * 60;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
|
||||
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
|
||||
Matcher s = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*s").matcher(lower);
|
||||
if (s.find()) {
|
||||
totalSeconds += Double.parseDouble(s.group(1));
|
||||
matched = true;
|
||||
}
|
||||
|
||||
return totalSeconds;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PricingPolicyMachineHourTier> tiers = tierRepo.findAllByPricingPolicyOrderByTierStartHoursAsc(policy);
|
||||
if (tiers.isEmpty()) {
|
||||
return BigDecimal.ZERO; // Should not happen if DB is correct
|
||||
}
|
||||
|
||||
List<String> notes = new ArrayList<>();
|
||||
// notes.add("Generated via Dynamic Slicer (Java Backend)");
|
||||
BigDecimal remainingHours = hours;
|
||||
BigDecimal totalCost = BigDecimal.ZERO;
|
||||
BigDecimal processedHours = BigDecimal.ZERO;
|
||||
|
||||
return new QuoteResult(totalPrice, "CHF", stats, breakdown, notes);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@@ -36,12 +37,21 @@ public class SlicerService {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
|
||||
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
|
||||
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
|
||||
// 1. Prepare Profiles
|
||||
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
||||
|
||||
// Apply Overrides
|
||||
if (machineOverrides != null) {
|
||||
machineOverrides.forEach(machineProfile::put);
|
||||
}
|
||||
if (processOverrides != null) {
|
||||
processOverrides.forEach(processProfile::put);
|
||||
}
|
||||
|
||||
// 2. Create Temp Dir
|
||||
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
366
db.sql
Normal file
366
db.sql
Normal file
@@ -0,0 +1,366 @@
|
||||
create table printer_machine
|
||||
(
|
||||
printer_machine_id bigserial primary key,
|
||||
printer_display_name text not null unique,
|
||||
|
||||
build_volume_x_mm integer not null check (build_volume_x_mm > 0),
|
||||
build_volume_y_mm integer not null check (build_volume_y_mm > 0),
|
||||
build_volume_z_mm integer not null check (build_volume_z_mm > 0),
|
||||
|
||||
power_watts integer not null check (power_watts > 0),
|
||||
|
||||
fleet_weight numeric(6, 3) not null default 1.000,
|
||||
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create view printer_fleet_current as
|
||||
select 1 as fleet_id,
|
||||
case
|
||||
when sum(fleet_weight) = 0 then null
|
||||
else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer
|
||||
end as weighted_average_power_watts,
|
||||
max(build_volume_x_mm) as fleet_max_build_x_mm,
|
||||
max(build_volume_y_mm) as fleet_max_build_y_mm,
|
||||
max(build_volume_z_mm) as fleet_max_build_z_mm
|
||||
from printer_machine
|
||||
where is_active = true;
|
||||
|
||||
|
||||
|
||||
create table filament_material_type
|
||||
(
|
||||
filament_material_type_id bigserial primary key,
|
||||
material_code text not null unique, -- PLA, PETG, TPU, ASA...
|
||||
is_flexible boolean not null default false, -- sì/no
|
||||
is_technical boolean not null default false, -- sì/no
|
||||
technical_type_label text -- es: "alta temperatura", "rinforzato", ecc.
|
||||
);
|
||||
|
||||
create table filament_variant
|
||||
(
|
||||
filament_variant_id bigserial primary key,
|
||||
filament_material_type_id bigint not null references filament_material_type (filament_material_type_id),
|
||||
|
||||
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
|
||||
color_name text not null, -- Nero, Bianco, ecc.
|
||||
is_matte boolean not null default false,
|
||||
is_special boolean not null default false,
|
||||
|
||||
cost_chf_per_kg numeric(10, 2) not null,
|
||||
|
||||
-- Stock espresso in rotoli anche frazionati
|
||||
stock_spools numeric(6, 3) not null default 0.000,
|
||||
spool_net_kg numeric(6, 3) not null default 1.000,
|
||||
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
|
||||
unique (filament_material_type_id, variant_display_name)
|
||||
);
|
||||
|
||||
-- (opzionale) kg disponibili calcolati
|
||||
create view filament_variant_stock_kg as
|
||||
select filament_variant_id,
|
||||
stock_spools,
|
||||
spool_net_kg,
|
||||
(stock_spools * spool_net_kg) as stock_kg
|
||||
from filament_variant;
|
||||
|
||||
|
||||
|
||||
create table pricing_policy
|
||||
(
|
||||
pricing_policy_id bigserial primary key,
|
||||
|
||||
policy_name text not null, -- es: "2026 Q1", "Default", ecc.
|
||||
|
||||
-- validità temporale (consiglio: valid_to esclusiva)
|
||||
valid_from timestamptz not null,
|
||||
valid_to timestamptz,
|
||||
|
||||
electricity_cost_chf_per_kwh numeric(10, 6) not null,
|
||||
markup_percent numeric(6, 3) not null default 20.000,
|
||||
|
||||
fixed_job_fee_chf numeric(10, 2) not null default 0.00, -- "costo fisso"
|
||||
nozzle_change_base_fee_chf numeric(10, 2) not null default 0.00, -- base cambio ugello, se vuoi
|
||||
cad_cost_chf_per_hour numeric(10, 2) not null default 0.00,
|
||||
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table pricing_policy_machine_hour_tier
|
||||
(
|
||||
pricing_policy_machine_hour_tier_id bigserial primary key,
|
||||
pricing_policy_id bigint not null references pricing_policy (pricing_policy_id),
|
||||
|
||||
tier_start_hours numeric(10, 2) not null,
|
||||
tier_end_hours numeric(10, 2), -- null = infinito
|
||||
machine_cost_chf_per_hour numeric(10, 2) not null,
|
||||
|
||||
constraint chk_tier_start_non_negative check (tier_start_hours >= 0),
|
||||
constraint chk_tier_end_gt_start check (tier_end_hours is null or tier_end_hours > tier_start_hours)
|
||||
);
|
||||
|
||||
create index idx_pricing_policy_validity
|
||||
on pricing_policy (valid_from, valid_to);
|
||||
|
||||
create index idx_pricing_tier_lookup
|
||||
on pricing_policy_machine_hour_tier (pricing_policy_id, tier_start_hours);
|
||||
|
||||
|
||||
create table nozzle_option
|
||||
(
|
||||
nozzle_option_id bigserial primary key,
|
||||
nozzle_diameter_mm numeric(4, 2) not null unique, -- 0.4, 0.6, 0.8...
|
||||
|
||||
owned_quantity integer not null default 0 check (owned_quantity >= 0),
|
||||
|
||||
-- extra costo specifico oltre ad eventuale base fee della pricing_policy
|
||||
extra_nozzle_change_fee_chf numeric(10, 2) not null default 0.00,
|
||||
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
|
||||
create table layer_height_option
|
||||
(
|
||||
layer_height_option_id bigserial primary key,
|
||||
layer_height_mm numeric(5, 3) not null unique, -- 0.12, 0.20, 0.28...
|
||||
|
||||
-- opzionale: moltiplicatore costo/tempo (es: 0.12 costa di più)
|
||||
time_multiplier numeric(6, 3) not null default 1.000,
|
||||
|
||||
is_active boolean not null default true
|
||||
);
|
||||
|
||||
create table layer_height_profile
|
||||
(
|
||||
layer_height_profile_id bigserial primary key,
|
||||
profile_name text not null unique, -- "Standard", "Fine", ecc.
|
||||
|
||||
min_layer_height_mm numeric(5, 3) not null,
|
||||
max_layer_height_mm numeric(5, 3) not null,
|
||||
default_layer_height_mm numeric(5, 3) not null,
|
||||
|
||||
time_multiplier numeric(6, 3) not null default 1.000,
|
||||
|
||||
constraint chk_layer_range check (max_layer_height_mm >= min_layer_height_mm)
|
||||
);
|
||||
|
||||
|
||||
begin;
|
||||
|
||||
set timezone = 'Europe/Zurich';
|
||||
|
||||
is_active = excluded.is_active;
|
||||
|
||||
|
||||
-- =========================================================
|
||||
-- 1) Pricing policy (valori ESATTI da Excel)
|
||||
-- Valid from: 2026-01-01, valid_to: NULL
|
||||
-- =========================================================
|
||||
insert into pricing_policy (
|
||||
policy_name,
|
||||
valid_from,
|
||||
valid_to,
|
||||
electricity_cost_chf_per_kwh,
|
||||
markup_percent,
|
||||
fixed_job_fee_chf,
|
||||
nozzle_change_base_fee_chf,
|
||||
cad_cost_chf_per_hour,
|
||||
is_active
|
||||
) values (
|
||||
'Excel Tariffe 2026-01-01',
|
||||
'2026-01-01 00:00:00+01'::timestamptz,
|
||||
null,
|
||||
0.156, -- Costo elettricità CHF/kWh (Excel)
|
||||
0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo)
|
||||
1.00, -- Costo fisso macchina CHF (Excel)
|
||||
0.00, -- Base cambio ugello: non specificato -> 0
|
||||
25.00, -- Tariffa CAD CHF/h (Excel)
|
||||
true
|
||||
)
|
||||
on conflict do nothing;
|
||||
|
||||
-- scaglioni tariffa stampa (Excel)
|
||||
insert into pricing_policy_machine_hour_tier (
|
||||
pricing_policy_id,
|
||||
tier_start_hours,
|
||||
tier_end_hours,
|
||||
machine_cost_chf_per_hour
|
||||
)
|
||||
select
|
||||
p.pricing_policy_id,
|
||||
tiers.tier_start_hours,
|
||||
tiers.tier_end_hours,
|
||||
tiers.machine_cost_chf_per_hour
|
||||
from pricing_policy p
|
||||
cross join (
|
||||
values
|
||||
(0.00::numeric, 10.00::numeric, 2.00::numeric), -- 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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,10 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DB_URL=jdbc:postgresql://db:5432/printcalc
|
||||
- DB_USERNAME=printcalc
|
||||
- DB_PASSWORD=printcalc_secret
|
||||
- SPRING_PROFILES_ACTIVE=local
|
||||
- FILAMENT_COST_PER_KG=22.0
|
||||
- MACHINE_COST_PER_HOUR=2.50
|
||||
- ENERGY_COST_PER_KWH=0.30
|
||||
@@ -16,10 +20,14 @@ services:
|
||||
- MARKUP_PERCENT=20
|
||||
- TEMP_DIR=/app/temp
|
||||
- PROFILES_DIR=/app/profiles
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: print-calculator-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
15
frontend/Dockerfile.dev
Normal file
15
frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20 as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
# Use development configuration to pick up environment.ts (localhost)
|
||||
RUN npm run build -- --configuration=development
|
||||
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -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)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
|
||||
<a routerLink="/cal" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
|
||||
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
|
||||
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
|
||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
|
||||
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { QuoteResultComponent } from './components/quote-result/quote-result.com
|
||||
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
|
||||
import { UserDetailsComponent } from './components/user-details/user-details.component';
|
||||
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calculator-page',
|
||||
@@ -18,7 +18,7 @@ import { Router } from '@angular/router';
|
||||
templateUrl: './calculator-page.component.html',
|
||||
styleUrl: './calculator-page.component.scss'
|
||||
})
|
||||
export class CalculatorPageComponent {
|
||||
export class CalculatorPageComponent implements OnInit {
|
||||
mode = signal<any>('easy');
|
||||
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
||||
|
||||
@@ -32,7 +32,19 @@ export class CalculatorPageComponent {
|
||||
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
|
||||
@ViewChild('resultCol') resultCol!: ElementRef;
|
||||
|
||||
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
||||
constructor(
|
||||
private estimator: QuoteEstimatorService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.subscribe(data => {
|
||||
if (data['mode']) {
|
||||
this.mode.set(data['mode']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCalculate(req: QuoteRequest) {
|
||||
// ... (logic remains the same, simplified for diff)
|
||||
|
||||
@@ -2,5 +2,7 @@ import { Routes } from '@angular/router';
|
||||
import { CalculatorPageComponent } from './calculator-page.component';
|
||||
|
||||
export const CALCULATOR_ROUTES: Routes = [
|
||||
{ path: '', component: CalculatorPageComponent }
|
||||
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
||||
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
|
||||
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
|
||||
];
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
|
||||
</div>
|
||||
|
||||
@if (result().notes) {
|
||||
<div class="notes-section">
|
||||
<label>{{ 'CALC.NOTES' | translate }}:</label>
|
||||
<p>{{ result().notes }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<label>COLORE</label>
|
||||
<app-color-selector
|
||||
[selectedColor]="item.color"
|
||||
[variants]="currentMaterialVariants()"
|
||||
(colorSelected)="updateItemColor(i, $event)">
|
||||
</app-color-selector>
|
||||
</div>
|
||||
@@ -80,20 +81,20 @@
|
||||
<app-select
|
||||
formControlName="material"
|
||||
[label]="'CALC.MATERIAL' | translate"
|
||||
[options]="materials"
|
||||
[options]="materials()"
|
||||
></app-select>
|
||||
|
||||
@if (mode() === 'easy') {
|
||||
<app-select
|
||||
formControlName="quality"
|
||||
[label]="'CALC.QUALITY' | translate"
|
||||
[options]="qualities"
|
||||
[options]="qualities()"
|
||||
></app-select>
|
||||
} @else {
|
||||
<app-select
|
||||
formControlName="nozzleDiameter"
|
||||
[label]="'CALC.NOZZLE' | translate"
|
||||
[options]="nozzleDiameters"
|
||||
[options]="nozzleDiameters()"
|
||||
></app-select>
|
||||
}
|
||||
</div>
|
||||
@@ -105,13 +106,13 @@
|
||||
<app-select
|
||||
formControlName="infillPattern"
|
||||
[label]="'CALC.PATTERN' | translate"
|
||||
[options]="infillPatterns"
|
||||
[options]="infillPatterns()"
|
||||
></app-select>
|
||||
|
||||
<app-select
|
||||
formControlName="layerHeight"
|
||||
[label]="'CALC.LAYER_HEIGHT' | translate"
|
||||
[options]="layerHeights"
|
||||
[options]="layerHeights()"
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
@@ -128,12 +129,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
<app-input
|
||||
formControlName="notes"
|
||||
[label]="'CALC.NOTES' | translate"
|
||||
placeholder="Istruzioni specifiche..."
|
||||
></app-input>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
||||
@@ -147,7 +149,7 @@
|
||||
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="form.invalid || items().length === 0 || loading()"
|
||||
[disabled]="items().length === 0 || loading()"
|
||||
[fullWidth]="true">
|
||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||
</app-button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output, signal, effect } from '@angular/core';
|
||||
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -8,7 +8,7 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
|
||||
interface FormItem {
|
||||
@@ -24,69 +24,110 @@ interface FormItem {
|
||||
templateUrl: './upload-form.component.html',
|
||||
styleUrl: './upload-form.component.scss'
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
export class UploadFormComponent implements OnInit {
|
||||
mode = input<'easy' | 'advanced'>('easy');
|
||||
loading = input<boolean>(false);
|
||||
uploadProgress = input<number>(0);
|
||||
submitRequest = output<QuoteRequest>();
|
||||
|
||||
private estimator = inject(QuoteEstimatorService);
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
items = signal<FormItem[]>([]);
|
||||
selectedFile = signal<File | null>(null);
|
||||
|
||||
materials = [
|
||||
{ label: 'PLA (Standard)', value: 'PLA' },
|
||||
{ label: 'PETG (Resistente)', value: 'PETG' },
|
||||
{ label: 'TPU (Flessibile)', value: 'TPU' }
|
||||
];
|
||||
// Dynamic Options
|
||||
materials = signal<SimpleOption[]>([]);
|
||||
qualities = signal<SimpleOption[]>([]);
|
||||
nozzleDiameters = signal<SimpleOption[]>([]);
|
||||
infillPatterns = signal<SimpleOption[]>([]);
|
||||
layerHeights = signal<SimpleOption[]>([]);
|
||||
|
||||
qualities = [
|
||||
{ label: 'Bozza (Fast)', value: 'Draft' },
|
||||
{ label: 'Standard', value: 'Standard' },
|
||||
{ label: 'Alta definizione', value: 'High' }
|
||||
];
|
||||
// Store full material options to lookup variants/colors if needed later
|
||||
private fullMaterialOptions: MaterialOption[] = [];
|
||||
|
||||
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 }
|
||||
];
|
||||
// Computed variants for valid material
|
||||
currentMaterialVariants = signal<VariantOption[]>([]);
|
||||
|
||||
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 }
|
||||
];
|
||||
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[]) {
|
||||
@@ -150,6 +191,11 @@ export class UploadFormComponent {
|
||||
|
||||
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';
|
||||
@@ -187,13 +233,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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -35,6 +35,7 @@ export interface QuoteResult {
|
||||
totalTimeHours: number;
|
||||
totalTimeMinutes: number;
|
||||
totalWeight: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface BackendResponse {
|
||||
@@ -49,14 +50,82 @@ 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;
|
||||
label: string;
|
||||
variants: VariantOption[];
|
||||
}
|
||||
export interface VariantOption {
|
||||
name: string;
|
||||
colorName: string;
|
||||
hexColor: string;
|
||||
isOutOfStock: boolean;
|
||||
}
|
||||
export interface QualityOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
export interface InfillOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
export interface NumericOption {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OptionsResponse {
|
||||
materials: MaterialOption[];
|
||||
qualities: QualityOption[];
|
||||
infillPatterns: InfillOption[];
|
||||
layerHeights: NumericOption[];
|
||||
nozzleDiameters: NumericOption[];
|
||||
}
|
||||
|
||||
// UI Option for Select Component
|
||||
export interface SimpleOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getOptions(): Observable<OptionsResponse> {
|
||||
console.log('QuoteEstimatorService: Requesting options...');
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
||||
tap({
|
||||
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
||||
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
if (request.items.length === 0) return of();
|
||||
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||
if (request.items.length === 0) {
|
||||
console.warn('QuoteEstimatorService: No items to calculate');
|
||||
return of();
|
||||
}
|
||||
|
||||
return new Observable(observer => {
|
||||
const totalItems = request.items.length;
|
||||
@@ -67,7 +136,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));
|
||||
|
||||
@@ -86,7 +172,7 @@ export class QuoteEstimatorService {
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
|
||||
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
|
||||
return this.http.post<BackendResponse | BackendQuoteResult>(`${environment.apiUrl}/api/quote`, formData, {
|
||||
headers,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
@@ -104,9 +190,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;
|
||||
@@ -136,6 +219,8 @@ 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;
|
||||
@@ -144,29 +229,37 @@ export class QuoteEstimatorService {
|
||||
const items: QuoteItem[] = [];
|
||||
|
||||
finalResponses.forEach((res, idx) => {
|
||||
if (res && res.success) {
|
||||
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: res.data.cost.total,
|
||||
unitTime: res.data.print_time_seconds,
|
||||
unitWeight: res.data.material_grams,
|
||||
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) {
|
||||
// If at least one failed? Or all?
|
||||
// For now if NO items succeeded, error.
|
||||
observer.error('All calculations failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial Aggregation
|
||||
let grandTotal = setupCost;
|
||||
const useBackendSetup = setupCostFromBackend != null;
|
||||
let grandTotal = useBackendSetup ? 0 : setupCost;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
@@ -181,12 +274,13 @@ 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,
|
||||
totalWeight: Math.ceil(totalWeight)
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: request.notes
|
||||
};
|
||||
|
||||
observer.next(result);
|
||||
@@ -196,7 +290,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');
|
||||
@@ -207,6 +300,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';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<app-button variant="primary" routerLink="/cal">Calcola Preventivo</app-button>
|
||||
<app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
|
||||
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
||||
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
<li>Ricevi subito costo e tempo</li>
|
||||
</ul>
|
||||
<div class="quote-actions">
|
||||
<app-button variant="primary" [fullWidth]="true" routerLink="/cal">Apri calcolatore</app-button>
|
||||
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
|
||||
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
@if (isOpen()) {
|
||||
<div class="color-popup">
|
||||
@for (category of categories; track category.name) {
|
||||
@for (category of categories(); track category.name) {
|
||||
<div class="category">
|
||||
<div class="category-name">{{ category.name }}</div>
|
||||
<div class="colors-grid">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
|
||||
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-color-selector',
|
||||
@@ -12,11 +13,28 @@ import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../.
|
||||
})
|
||||
export class ColorSelectorComponent {
|
||||
selectedColor = input<string>('Black');
|
||||
variants = input<VariantOption[]>([]);
|
||||
colorSelected = output<string>();
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
categories: ColorCategory[] = PRODUCT_COLORS;
|
||||
categories = computed(() => {
|
||||
const vars = this.variants();
|
||||
if (vars && vars.length > 0) {
|
||||
// Flatten variants into a single category for now
|
||||
// We could try to group by extracting words, but "Colors" is fine.
|
||||
return [{
|
||||
name: 'Available Colors',
|
||||
colors: vars.map(v => ({
|
||||
label: v.colorName, // Display "Red"
|
||||
value: v.colorName, // Send "Red" to backend
|
||||
hex: v.hexColor,
|
||||
outOfStock: v.isOutOfStock
|
||||
}))
|
||||
}] as ColorCategory[];
|
||||
}
|
||||
return PRODUCT_COLORS;
|
||||
});
|
||||
|
||||
toggleOpen() {
|
||||
this.isOpen.update(v => !v);
|
||||
@@ -31,6 +49,13 @@ export class ColorSelectorComponent {
|
||||
|
||||
// Helper to find hex for the current selected value
|
||||
getCurrentHex(): string {
|
||||
// Check in dynamic variants first
|
||||
const vars = this.variants();
|
||||
if (vars && vars.length > 0) {
|
||||
const found = vars.find(v => v.colorName === this.selectedColor());
|
||||
if (found) return found.hexColor;
|
||||
}
|
||||
|
||||
return getColorHex(this.selectedColor());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://3d-fab.ch',
|
||||
apiUrl: '',
|
||||
basicAuth: ''
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user