From 1a36808d9f8af17dd80cde5e18e47ae8dc7f5b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 15:01:40 +0100 Subject: [PATCH] feat(front-end and back-end): new nozle option, also fix quantity reload and reorganized service in back-end --- .../CustomQuoteRequestController.java | 2 +- .../controller/OptionsController.java | 63 ++++-- .../controller/OrderController.java | 10 +- .../controller/QuoteController.java | 36 +++- .../controller/QuoteSessionController.java | 36 +++- .../admin/AdminOrderController.java | 9 +- .../printcalculator/dto/OptionsResponse.java | 4 +- .../entity/NozzleLayerHeightOption.java | 63 ++++++ .../event/listener/OrderEmailListener.java | 6 +- .../NozzleLayerHeightOptionRepository.java | 10 + .../NozzleLayerHeightPolicyService.java | 143 +++++++++++++ .../printcalculator/service/OrderService.java | 4 + .../InvoicePdfRenderingService.java | 2 +- .../service/{ => payment}/PaymentService.java | 4 +- .../service/{ => payment}/QrBillService.java | 5 +- .../{ => payment}/TwintPaymentService.java | 2 +- .../service/{ => storage}/ClamAVService.java | 2 +- .../FileSystemStorageService.java | 3 +- .../service/{ => storage}/StorageService.java | 2 +- .../OrderControllerPrivacyTest.java | 10 +- ...inOrderControllerStatusValidationTest.java | 8 +- .../listener/OrderEmailListenerTest.java | 6 +- .../calculator/calculator-page.component.html | 8 +- .../calculator/calculator-page.component.ts | 191 +++++++++++++++++- .../upload-form/upload-form.component.ts | 164 ++++++++++++++- .../services/quote-estimator.service.ts | 6 + 26 files changed, 712 insertions(+), 87 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java create mode 100644 backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java create mode 100644 backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java rename backend/src/main/java/com/printcalculator/service/{ => payment}/InvoicePdfRenderingService.java (99%) rename backend/src/main/java/com/printcalculator/service/{ => payment}/PaymentService.java (96%) rename backend/src/main/java/com/printcalculator/service/{ => payment}/QrBillService.java (94%) rename backend/src/main/java/com/printcalculator/service/{ => payment}/TwintPaymentService.java (98%) rename backend/src/main/java/com/printcalculator/service/{ => storage}/ClamAVService.java (98%) rename backend/src/main/java/com/printcalculator/service/{ => storage}/FileSystemStorageService.java (98%) rename backend/src/main/java/com/printcalculator/service/{ => storage}/StorageService.java (91%) diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 003ade8..908ef71 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -5,7 +5,7 @@ import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.service.ClamAVService; +import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.email.EmailNotificationService; import jakarta.validation.Valid; import org.slf4j.Logger; diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index e187422..56cd26b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -3,17 +3,16 @@ package com.printcalculator.controller; import com.printcalculator.dto.OptionsResponse; import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.LayerHeightOption; import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachineProfile; import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.LayerHeightOptionRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.OrcaProfileResolver; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -32,26 +32,26 @@ public class OptionsController { private final FilamentMaterialTypeRepository materialRepo; private final FilamentVariantRepository variantRepo; - private final LayerHeightOptionRepository layerHeightRepo; private final NozzleOptionRepository nozzleRepo; private final PrinterMachineRepository printerMachineRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final OrcaProfileResolver orcaProfileResolver; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, - LayerHeightOptionRepository layerHeightRepo, NozzleOptionRepository nozzleRepo, PrinterMachineRepository printerMachineRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo, - OrcaProfileResolver orcaProfileResolver) { + OrcaProfileResolver orcaProfileResolver, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; - this.layerHeightRepo = layerHeightRepo; this.nozzleRepo = nozzleRepo; this.printerMachineRepo = printerMachineRepo; this.materialOrcaMapRepo = materialOrcaMapRepo; this.orcaProfileResolver = orcaProfileResolver; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @GetMapping("/api/calculator/options") @@ -116,15 +116,6 @@ public class OptionsController { new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); - List layers = layerHeightRepo.findAll().stream() - .filter(l -> Boolean.TRUE.equals(l.getIsActive())) - .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) - .map(l -> new OptionsResponse.LayerHeightOptionDTO( - l.getLayerHeightMm().doubleValue(), - String.format("%.2f mm", l.getLayerHeightMm()) - )) - .toList(); - List nozzles = nozzleRepo.findAll().stream() .filter(n -> Boolean.TRUE.equals(n.getIsActive())) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) @@ -137,7 +128,31 @@ public class OptionsController { )) .toList(); - return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); + Map> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); + BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); + + List layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of())); + if (layers.isEmpty()) { + layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + } + + List layerHeightsByNozzle = rulesByNozzle.entrySet().stream() + .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO( + entry.getKey().doubleValue(), + toLayerDtos(entry.getValue()) + )) + .toList(); + + return ResponseEntity.ok(new OptionsResponse( + materialOptions, + qualities, + patterns, + layers, + nozzles, + layerHeightsByNozzle + )); } private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { @@ -152,9 +167,9 @@ public class OptionsController { return Set.of(); } - BigDecimal nozzle = nozzleDiameter != null - ? BigDecimal.valueOf(nozzleDiameter) - : BigDecimal.valueOf(0.40); + BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); PrinterMachineProfile machineProfile = orcaProfileResolver .resolveMachineProfile(machine, nozzle) @@ -172,6 +187,16 @@ public class OptionsController { .collect(Collectors.toSet()); } + private List toLayerDtos(List layers) { + return layers.stream() + .sorted(Comparator.naturalOrder()) + .map(layer -> new OptionsResponse.LayerHeightOptionDTO( + layer.doubleValue(), + String.format("%.2f mm", layer) + )) + .toList(); + } + private String resolveHexColor(FilamentVariant variant) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { return variant.getColorHex(); diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index c8a2b5c..eb11a76 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -3,12 +3,12 @@ package com.printcalculator.controller; import com.printcalculator.dto.*; import com.printcalculator.entity.*; import com.printcalculator.repository.*; -import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; -import com.printcalculator.service.TwintPaymentService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import com.printcalculator.service.payment.TwintPaymentService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 45369c1..8675415 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -4,17 +4,23 @@ import com.printcalculator.entity.PrinterMachine; import com.printcalculator.model.PrintStats; import com.printcalculator.model.QuoteResult; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.SlicerService; +import com.printcalculator.service.storage.ClamAVService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; -import java.util.Map; -import java.util.HashMap; import java.io.IOException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; @RestController public class QuoteController { @@ -22,17 +28,23 @@ public class QuoteController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; - private final com.printcalculator.service.ClamAVService clamAVService; + private final ClamAVService clamAVService; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; // Defaults (using aliases defined in ProfileManager) private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_PROCESS = "standard"; - public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { + public QuoteController(SlicerService slicerService, + QuoteCalculator quoteCalculator, + PrinterMachineRepository machineRepo, + ClamAVService clamAVService, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; this.clamAVService = clamAVService; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @PostMapping("/api/quote") @@ -69,15 +81,27 @@ public class QuoteController { if (infillPattern != null && !infillPattern.isEmpty()) { processOverrides.put("sparse_infill_pattern", infillPattern); } + BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); if (layerHeight != null) { - processOverrides.put("layer_height", String.valueOf(layerHeight)); + BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight)); + if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Layer height " + normalizedLayer.stripTrailingZeros().toPlainString() + + " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString() + + ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle) + ); + } + processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString()); } if (supportEnabled != null) { processOverrides.put("enable_support", supportEnabled ? "1" : "0"); } if (nozzleDiameter != null) { - machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter)); + machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString()); // 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. diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 70689ac..803a045 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -14,9 +14,11 @@ import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.SlicerService; +import com.printcalculator.service.storage.ClamAVService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -61,8 +63,9 @@ public class QuoteSessionController { private final FilamentMaterialTypeRepository materialRepo; private final FilamentVariantRepository variantRepo; private final OrcaProfileResolver orcaProfileResolver; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; - private final com.printcalculator.service.ClamAVService clamAVService; + private final ClamAVService clamAVService; private final QuoteSessionTotalsService quoteSessionTotalsService; public QuoteSessionController(QuoteSessionRepository sessionRepo, @@ -73,8 +76,9 @@ public class QuoteSessionController { FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, OrcaProfileResolver orcaProfileResolver, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService, com.printcalculator.repository.PricingPolicyRepository pricingRepo, - com.printcalculator.service.ClamAVService clamAVService, + ClamAVService clamAVService, QuoteSessionTotalsService quoteSessionTotalsService) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; @@ -84,6 +88,7 @@ public class QuoteSessionController { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.orcaProfileResolver = orcaProfileResolver; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; this.pricingRepo = pricingRepo; this.clamAVService = clamAVService; this.quoteSessionTotalsService = quoteSessionTotalsService; @@ -167,7 +172,23 @@ public class QuoteSessionController { applyPrintSettings(settings); } - BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); + BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle( + settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null + ); + BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer( + settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null, + nozzleDiameter + ); + if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Layer height " + layerHeight.stripTrailingZeros().toPlainString() + + " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString() + + ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter) + ); + } + settings.setNozzleDiameter(nozzleDiameter.doubleValue()); + settings.setLayerHeight(layerHeight.doubleValue()); // Pick machine (selected machine if provided, otherwise first active) PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); @@ -190,7 +211,7 @@ public class QuoteSessionController { if (!cadSession) { session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); session.setNozzleDiameterMm(nozzleDiameter); - session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); + session.setLayerHeightMm(layerHeight); session.setInfillPattern(settings.getInfillPattern()); session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); @@ -209,7 +230,7 @@ public class QuoteSessionController { // Build overrides map from settings Map processOverrides = new HashMap<>(); - if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); + processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString()); if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); @@ -289,6 +310,10 @@ public class QuoteSessionController { } private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { + if (settings.getNozzleDiameter() == null) { + settings.setNozzleDiameter(0.40); + } + if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { // Set defaults based on Quality String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; @@ -313,7 +338,6 @@ public class QuoteSessionController { } } else { // ADVANCED Mode: Use values from Frontend, set defaults if missing - if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 764940d..c41e8c1 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -12,10 +12,10 @@ import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -34,7 +34,6 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Locale; import java.util.Map; diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index 54d7e87..9b85460 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -7,7 +7,8 @@ public record OptionsResponse( List qualities, List infillPatterns, List layerHeights, - List nozzleDiameters + List nozzleDiameters, + List layerHeightsByNozzle ) { public record MaterialOption(String code, String label, List variants) {} public record VariantOption( @@ -24,4 +25,5 @@ public record OptionsResponse( public record InfillPatternOption(String id, String label) {} public record LayerHeightOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {} + public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List layerHeights) {} } diff --git a/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java b/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java new file mode 100644 index 0000000..bf2de56 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java @@ -0,0 +1,63 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "nozzle_layer_height_option", + uniqueConstraints = @UniqueConstraint( + name = "ux_nozzle_layer_height_option_nozzle_layer", + columnNames = {"nozzle_diameter_mm", "layer_height_mm"} + ) +) +public class NozzleLayerHeightOption { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "nozzle_layer_height_option_id", nullable = false) + private Long id; + + @Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2) + private BigDecimal nozzleDiameterMm; + + @Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3) + private BigDecimal layerHeightMm; + + @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 getNozzleDiameterMm() { + return nozzleDiameterMm; + } + + public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { + this.nozzleDiameterMm = nozzleDiameterMm; + } + + public BigDecimal getLayerHeightMm() { + return layerHeightMm; + } + + public void setLayerHeightMm(BigDecimal layerHeightMm) { + this.layerHeightMm = layerHeightMm; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index a1cfaa2..27b349c 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -8,9 +8,9 @@ import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.event.PaymentConfirmedEvent; import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import com.printcalculator.service.email.EmailNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java new file mode 100644 index 0000000..38973fb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java @@ -0,0 +1,10 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.NozzleLayerHeightOption; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NozzleLayerHeightOptionRepository extends JpaRepository { + List findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc(); +} diff --git a/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java b/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java new file mode 100644 index 0000000..4898209 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java @@ -0,0 +1,143 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.NozzleLayerHeightOption; +import com.printcalculator.repository.NozzleLayerHeightOptionRepository; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class NozzleLayerHeightPolicyService { + private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP); + private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP); + + private final NozzleLayerHeightOptionRepository ruleRepo; + + public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) { + this.ruleRepo = ruleRepo; + } + + public Map> getActiveRulesByNozzle() { + List rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc(); + if (rules.isEmpty()) { + return fallbackRules(); + } + + Map> byNozzle = new LinkedHashMap<>(); + for (NozzleLayerHeightOption rule : rules) { + BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm()); + BigDecimal layer = normalizeLayer(rule.getLayerHeightMm()); + if (nozzle == null || layer == null) { + continue; + } + byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer); + } + + byNozzle.values().forEach(this::sortAndDeduplicate); + return byNozzle; + } + + public BigDecimal normalizeNozzle(BigDecimal value) { + if (value == null) { + return null; + } + return value.setScale(2, RoundingMode.HALF_UP); + } + + public BigDecimal normalizeLayer(BigDecimal value) { + if (value == null) { + return null; + } + return value.setScale(3, RoundingMode.HALF_UP); + } + + public BigDecimal resolveNozzle(BigDecimal requestedNozzle) { + return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE); + } + + public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) { + if (requestedLayer != null) { + return normalizeLayer(requestedLayer); + } + return defaultLayerForNozzle(nozzleDiameter); + } + + public List allowedLayersForNozzle(BigDecimal nozzleDiameter) { + BigDecimal nozzle = resolveNozzle(nozzleDiameter); + List allowed = getActiveRulesByNozzle().get(nozzle); + return allowed != null ? allowed : List.of(); + } + + public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) { + BigDecimal layer = normalizeLayer(layerHeight); + if (layer == null) { + return false; + } + return allowedLayersForNozzle(nozzleDiameter) + .stream() + .anyMatch(allowed -> allowed.compareTo(layer) == 0); + } + + public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) { + List allowed = allowedLayersForNozzle(nozzleDiameter); + if (allowed.isEmpty()) { + return DEFAULT_LAYER; + } + + BigDecimal preferred = normalizeLayer(DEFAULT_LAYER); + for (BigDecimal candidate : allowed) { + if (candidate.compareTo(preferred) == 0) { + return candidate; + } + } + return allowed.get(0); + } + + public String allowedLayersLabel(BigDecimal nozzleDiameter) { + List allowed = allowedLayersForNozzle(nozzleDiameter); + if (allowed.isEmpty()) { + return "none"; + } + return allowed.stream() + .map(value -> String.format(Locale.ROOT, "%.2f", value)) + .reduce((a, b) -> a + ", " + b) + .orElse("none"); + } + + private void sortAndDeduplicate(List values) { + values.sort(Comparator.naturalOrder()); + for (int i = values.size() - 1; i > 0; i--) { + if (values.get(i).compareTo(values.get(i - 1)) == 0) { + values.remove(i); + } + } + } + + private Map> fallbackRules() { + Map> fallback = new LinkedHashMap<>(); + fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12)); + fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28)); + fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36)); + fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56)); + return fallback; + } + + private BigDecimal scaleNozzle(double value) { + return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP); + } + + private List scaleLayers(double... values) { + List scaled = new ArrayList<>(); + for (double value : values) { + scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP)); + } + return scaled; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 3a1f606..f42b17c 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -8,6 +8,10 @@ import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java similarity index 99% rename from backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java rename to backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java index 97840bf..96ec578 100644 --- a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import com.openhtmltopdf.svgsupport.BatikSVGDrawer; diff --git a/backend/src/main/java/com/printcalculator/service/PaymentService.java b/backend/src/main/java/com/printcalculator/service/payment/PaymentService.java similarity index 96% rename from backend/src/main/java/com/printcalculator/service/PaymentService.java rename to backend/src/main/java/com/printcalculator/service/payment/PaymentService.java index 23b907c..4be56c3 100644 --- a/backend/src/main/java/com/printcalculator/service/PaymentService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/PaymentService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.printcalculator.entity.Order; import com.printcalculator.entity.Payment; @@ -65,7 +65,7 @@ public class PaymentService { payment.setReportedAt(OffsetDateTime.now()); // We intentionally do not update the payment method here based on user input, - // because the user cannot reliably determine the actual method without an integration. + // because the system cannot reliably determine the actual method without an integration. // It will be updated by the backoffice admin manually. payment = paymentRepo.save(payment); diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/payment/QrBillService.java similarity index 94% rename from backend/src/main/java/com/printcalculator/service/QrBillService.java rename to backend/src/main/java/com/printcalculator/service/payment/QrBillService.java index 71eb47c..a6dbed7 100644 --- a/backend/src/main/java/com/printcalculator/service/QrBillService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/QrBillService.java @@ -1,13 +1,10 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.printcalculator.entity.Order; import net.codecrete.qrbill.generator.Bill; -import net.codecrete.qrbill.generator.GraphicsFormat; import net.codecrete.qrbill.generator.QRBill; import org.springframework.stereotype.Service; -import java.math.BigDecimal; - @Service public class QrBillService { diff --git a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java b/backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/TwintPaymentService.java rename to backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java index 6ed915b..a7be8eb 100644 --- a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import io.nayuki.qrcodegen.QrCode; import org.springframework.beans.factory.annotation.Value; diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/ClamAVService.java rename to backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java index dc6532a..ceed6e7 100644 --- a/backend/src/main/java/com/printcalculator/service/ClamAVService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import com.printcalculator.exception.VirusDetectedException; import org.slf4j.Logger; diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java rename to backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java index 38e1e25..d4bf9d3 100644 --- a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; @@ -7,7 +7,6 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import com.printcalculator.exception.StorageException; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; diff --git a/backend/src/main/java/com/printcalculator/service/StorageService.java b/backend/src/main/java/com/printcalculator/service/storage/StorageService.java similarity index 91% rename from backend/src/main/java/com/printcalculator/service/StorageService.java rename to backend/src/main/java/com/printcalculator/service/storage/StorageService.java index 5fe2321..89773a8 100644 --- a/backend/src/main/java/com/printcalculator/service/StorageService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/StorageService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import org.springframework.core.io.Resource; import org.springframework.web.multipart.MultipartFile; diff --git a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java index b3d6665..5849c12 100644 --- a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java +++ b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java @@ -8,12 +8,12 @@ import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; -import com.printcalculator.service.TwintPaymentService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import com.printcalculator.service.payment.TwintPaymentService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java index 88701dd..804da16 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -6,10 +6,10 @@ import com.printcalculator.entity.Order; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java index b0f62c4..6c06bc4 100644 --- a/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java +++ b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java @@ -4,9 +4,9 @@ import com.printcalculator.entity.Customer; import com.printcalculator.entity.Order; import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import com.printcalculator.service.email.EmailNotificationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 070b68d..8aab319 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -24,7 +24,7 @@ class="mode-option" [class.active]="mode() === 'easy'" [class.disabled]="cadSessionLocked()" - (click)="!cadSessionLocked() && mode.set('easy')" + (click)="switchMode('easy')" > {{ "CALC.MODE_EASY" | translate }} @@ -32,7 +32,7 @@ class="mode-option" [class.active]="mode() === 'advanced'" [class.disabled]="cadSessionLocked()" - (click)="!cadSessionLocked() && mode.set('advanced')" + (click)="switchMode('advanced')" > {{ "CALC.MODE_ADVANCED" | translate }} @@ -45,6 +45,8 @@ [loading]="loading()" [uploadProgress]="uploadProgress()" (submitRequest)="onCalculate($event)" + (itemQuantityChange)="onUploadItemQuantityChange($event)" + (printSettingsChange)="onUploadPrintSettingsChange($event)" > @@ -64,8 +66,10 @@ } @else if (result()) { } @else if (isZeroQuoteError()) { diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 6e56a60..3941639 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -42,7 +42,7 @@ import { LanguageService } from '../../core/services/language.service'; styleUrl: './calculator-page.component.scss', }) export class CalculatorPageComponent implements OnInit { - mode = signal('easy'); + mode = signal<'easy' | 'advanced'>('easy'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); loading = signal(false); @@ -56,6 +56,17 @@ export class CalculatorPageComponent implements OnInit { ); orderSuccess = signal(false); + requiresRecalculation = signal(false); + private baselinePrintSettings: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } | null = null; @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; @@ -101,6 +112,10 @@ export class CalculatorPageComponent implements OnInit { this.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(result); + this.baselinePrintSettings = this.toTrackedSettingsFromSession( + data.session, + ); + this.requiresRecalculation.set(false); const isCadSession = data?.session?.status === 'CAD_ACTIVE'; this.cadSessionLocked.set(isCadSession); this.step.set('quote'); @@ -238,6 +253,8 @@ export class CalculatorPageComponent implements OnInit { this.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(res); + this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req); + this.requiresRecalculation.set(false); this.loading.set(false); this.uploadProgress.set(100); this.step.set('quote'); @@ -295,9 +312,10 @@ export class CalculatorPageComponent implements OnInit { index: number; fileName: string; quantity: number; + source?: 'left' | 'right'; }) { // 1. Update local form for consistency (UI feedback) - if (this.uploadForm) { + if (event.source !== 'left' && this.uploadForm) { this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); } @@ -340,6 +358,33 @@ export class CalculatorPageComponent implements OnInit { } } + onUploadItemQuantityChange(event: { + index: number; + fileName: string; + quantity: number; + }) { + const resultItems = this.result()?.items || []; + const byIndex = resultItems[event.index]; + const byName = resultItems.find((item) => item.fileName === event.fileName); + const id = byIndex?.id ?? byName?.id; + + this.onItemChange({ + ...event, + id, + source: 'left', + }); + } + + onQuoteItemQuantityPreviewChange(event: { + index: number; + fileName: string; + quantity: number; + }) { + if (!this.uploadForm) return; + this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); + this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); + } + onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); @@ -349,15 +394,37 @@ export class CalculatorPageComponent implements OnInit { onNewQuote() { this.step.set('upload'); this.result.set(null); + this.requiresRecalculation.set(false); + this.baselinePrintSettings = null; this.cadSessionLocked.set(false); this.orderSuccess.set(false); - this.mode.set('easy'); // Reset to default + this.switchMode('easy'); // Reset to default and sync URL } private currentRequest: QuoteRequest | null = null; + onUploadPrintSettingsChange(settings: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }) { + if (!this.result()) return; + if (!this.baselinePrintSettings) return; + this.requiresRecalculation.set( + !this.sameTrackedSettings(this.baselinePrintSettings, settings), + ); + } + onConsult() { - if (!this.currentRequest) { + const currentFormRequest = this.uploadForm?.getCurrentRequestDraft(); + const req = currentFormRequest ?? this.currentRequest; + + if (!req) { this.router.navigate([ '/', this.languageService.selectedLang(), @@ -366,7 +433,6 @@ export class CalculatorPageComponent implements OnInit { return; } - const req = this.currentRequest; let details = `Richiesta Preventivo:\n`; details += `- Materiale: ${req.material}\n`; details += `- Qualità: ${req.quality}\n`; @@ -411,5 +477,120 @@ export class CalculatorPageComponent implements OnInit { this.errorKey.set(key); this.error.set(true); this.result.set(null); + this.requiresRecalculation.set(false); + this.baselinePrintSettings = null; + } + + switchMode(nextMode: 'easy' | 'advanced'): void { + if (this.cadSessionLocked()) return; + + const targetPath = nextMode === 'easy' ? 'basic' : 'advanced'; + const currentPath = this.route.snapshot.routeConfig?.path; + + this.mode.set(nextMode); + + if (currentPath === targetPath) { + return; + } + + this.router.navigate(['..', targetPath], { + relativeTo: this.route, + queryParamsHandling: 'preserve', + }); + } + + private toTrackedSettingsFromRequest(req: QuoteRequest): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + return { + mode: req.mode, + material: this.normalizeString(req.material || 'PLA'), + quality: this.normalizeString(req.quality || 'standard'), + nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2), + layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3), + infillDensity: this.normalizeNumber(req.infillDensity, 20, 2), + infillPattern: this.normalizeString(req.infillPattern || 'grid'), + supportEnabled: Boolean(req.supportEnabled), + }; + } + + private toTrackedSettingsFromSession(session: any): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3); + return { + mode: this.mode(), + material: this.normalizeString(session?.materialCode || 'PLA'), + quality: + layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard', + nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2), + layerHeight: layer, + infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2), + infillPattern: this.normalizeString(session?.infillPattern || 'grid'), + supportEnabled: Boolean(session?.supportsEnabled), + }; + } + + private sameTrackedSettings( + a: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }, + b: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }, + ): boolean { + return ( + a.mode === b.mode && + a.material === this.normalizeString(b.material) && + a.quality === this.normalizeString(b.quality) && + Math.abs(a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2)) < 0.0001 && + Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) < 0.0001 && + Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) < 0.0001 && + a.infillPattern === this.normalizeString(b.infillPattern) && + a.supportEnabled === Boolean(b.supportEnabled) + ); + } + + private normalizeString(value: string): string { + return String(value || '').trim().toLowerCase(); + } + + private normalizeNumber( + value: unknown, + fallback: number, + decimals: number, + ): number { + const numeric = Number(value); + const resolved = Number.isFinite(numeric) ? numeric : fallback; + const factor = 10 ** decimals; + return Math.round(resolved * factor) / factor; } } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 30d323e..a75300f 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -62,6 +62,21 @@ export class UploadFormComponent implements OnInit { loading = input(false); uploadProgress = input(0); submitRequest = output(); + itemQuantityChange = output<{ + index: number; + fileName: string; + quantity: number; + }>(); + printSettingsChange = output<{ + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }>(); private estimator = inject(QuoteEstimatorService); private fb = inject(FormBuilder); @@ -81,6 +96,8 @@ export class UploadFormComponent implements OnInit { // Store full material options to lookup variants/colors if needed later private fullMaterialOptions: MaterialOption[] = []; + private allLayerHeights: SimpleOption[] = []; + private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; // Computed variants for valid material @@ -141,6 +158,14 @@ export class UploadFormComponent implements OnInit { if (this.mode() !== 'easy' || this.isPatchingSettings) return; this.applyAdvancedPresetFromQuality(quality); }); + this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => { + if (this.isPatchingSettings) return; + this.updateLayerHeightOptionsForNozzle(nozzle, true); + }); + this.form.valueChanges.subscribe(() => { + if (this.isPatchingSettings) return; + this.emitPrintSettingsChange(); + }); effect(() => { this.applySettingsLock(this.lockedSettings()); @@ -187,6 +212,7 @@ export class UploadFormComponent implements OnInit { const preset = presets[normalized] || presets['standard']; this.form.patchValue(preset, { emitEvent: false }); + this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); } ngOnInit() { @@ -204,9 +230,19 @@ export class UploadFormComponent implements OnInit { 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.allLayerHeights = options.layerHeights.map((l) => ({ + label: l.label, + value: l.value, + })); + this.layerHeightsByNozzle = {}; + (options.layerHeightsByNozzle || []).forEach((entry) => { + this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] = + entry.layerHeights.map((layer) => ({ + label: layer.label, + value: layer.value, + })); + }); + this.layerHeights.set(this.allLayerHeights); this.nozzleDiameters.set( options.nozzleDiameters.map((n) => ({ label: n.label, @@ -231,6 +267,11 @@ export class UploadFormComponent implements OnInit { value: 'standard', }, ]); + this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }]; + this.layerHeightsByNozzle = { + [this.toNozzleKey(0.4)]: this.allLayerHeights, + }; + this.layerHeights.set(this.allLayerHeights); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.setDefaults(); }, @@ -240,7 +281,15 @@ export class UploadFormComponent implements OnInit { 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); + const exactPla = this.materials().find( + (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA', + ); + const anyPla = this.materials().find( + (m) => + typeof m.value === 'string' && m.value.toUpperCase().startsWith('PLA'), + ); + const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0]; + this.form.get('material')?.setValue(preferredMaterial.value); } if (this.qualities().length > 0 && !this.form.get('quality')?.value) { // Try to find 'standard' or use first @@ -255,18 +304,20 @@ export class UploadFormComponent implements OnInit { ) { 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 - } + + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + if ( this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value ) { this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); } + + this.emitPrintSettingsChange(); } onFilesDropped(newFiles: File[]) { @@ -369,7 +420,15 @@ export class UploadFormComponent implements OnInit { const input = event.target as HTMLInputElement; const parsed = parseInt(input.value, 10); const quantity = Number.isFinite(parsed) ? parsed : 1; + const currentItem = this.items()[index]; + if (!currentItem) return; + const normalizedQty = this.normalizeQuantity(quantity); this.updateItemQuantityByIndex(index, quantity); + this.itemQuantityChange.emit({ + index, + fileName: currentItem.file.name, + quantity: normalizedQty, + }); } updateItemColor( @@ -514,6 +573,11 @@ export class UploadFormComponent implements OnInit { this.isPatchingSettings = true; this.form.patchValue(patch, { emitEvent: false }); this.isPatchingSettings = false; + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + this.emitPrintSettingsChange(); } onSubmit() { @@ -561,6 +625,86 @@ export class UploadFormComponent implements OnInit { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } + private updateLayerHeightOptionsForNozzle( + nozzleValue: unknown, + preserveCurrent: boolean, + ): void { + const key = this.toNozzleKey(nozzleValue); + const nozzleSpecific = this.layerHeightsByNozzle[key] || []; + const available = + nozzleSpecific.length > 0 ? nozzleSpecific : this.allLayerHeights; + this.layerHeights.set(available); + + const control = this.form.get('layerHeight'); + if (!control) return; + + const currentValue = Number(control.value); + const currentAllowed = available.some( + (option) => Math.abs(Number(option.value) - currentValue) < 0.0001, + ); + if (preserveCurrent && currentAllowed) { + return; + } + + const preferred = available.find( + (option) => Math.abs(Number(option.value) - 0.2) < 0.0001, + ); + const next = preferred ?? available[0]; + if (next) { + control.setValue(next.value, { emitEvent: false }); + } + } + + private toNozzleKey(value: unknown): string { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return ''; + return numeric.toFixed(2); + } + + getCurrentRequestDraft(): QuoteRequest | null { + if (this.items().length === 0) return null; + const raw = this.form.getRawValue(); + return { + items: this.items(), + material: raw.material, + quality: raw.quality, + notes: raw.notes, + infillDensity: raw.infillDensity, + infillPattern: raw.infillPattern, + supportEnabled: raw.supportEnabled, + layerHeight: raw.layerHeight, + nozzleDiameter: raw.nozzleDiameter, + mode: this.mode(), + }; + } + + getCurrentPrintSettings(): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const raw = this.form.getRawValue(); + return { + mode: this.mode(), + material: String(raw.material || 'PLA'), + quality: String(raw.quality || 'standard'), + nozzleDiameter: Number(raw.nozzleDiameter ?? 0.4), + layerHeight: Number(raw.layerHeight ?? 0.2), + infillDensity: Number(raw.infillDensity ?? 20), + infillPattern: String(raw.infillPattern || 'grid'), + supportEnabled: Boolean(raw.supportEnabled), + }; + } + + private emitPrintSettingsChange(): void { + this.printSettingsChange.emit(this.getCurrentPrintSettings()); + } + private applySettingsLock(locked: boolean): void { const controlsToLock = [ 'material', diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 7c72852..fed6db7 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -102,12 +102,18 @@ export interface NumericOption { label: string; } +export interface NozzleLayerHeightsOption { + nozzleDiameter: number; + layerHeights: NumericOption[]; +} + export interface OptionsResponse { materials: MaterialOption[]; qualities: QualityOption[]; infillPatterns: InfillOption[]; layerHeights: NumericOption[]; nozzleDiameters: NumericOption[]; + layerHeightsByNozzle?: NozzleLayerHeightsOption[]; } // UI Option for Select Component