diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 7eb7251..3b2295b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -3,18 +3,27 @@ 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.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.OrcaProfileResolver; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; +import java.math.BigDecimal; import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @RestController @@ -24,89 +33,177 @@ public class OptionsController { private final FilamentVariantRepository variantRepo; private final LayerHeightOptionRepository layerHeightRepo; private final NozzleOptionRepository nozzleRepo; + private final PrinterMachineRepository printerMachineRepo; + private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; + private final OrcaProfileResolver orcaProfileResolver; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, LayerHeightOptionRepository layerHeightRepo, - NozzleOptionRepository nozzleRepo) { + NozzleOptionRepository nozzleRepo, + PrinterMachineRepository printerMachineRepo, + MaterialOrcaProfileMapRepository materialOrcaMapRepo, + OrcaProfileResolver orcaProfileResolver) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.layerHeightRepo = layerHeightRepo; this.nozzleRepo = nozzleRepo; + this.printerMachineRepo = printerMachineRepo; + this.materialOrcaMapRepo = materialOrcaMapRepo; + this.orcaProfileResolver = orcaProfileResolver; } @GetMapping("/api/calculator/options") - public ResponseEntity getOptions() { - // 1. Materials & Variants + public ResponseEntity getOptions( + @RequestParam(value = "printerMachineId", required = false) Long printerMachineId, + @RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter + ) { List types = materialRepo.findAll(); - List allVariants = variantRepo.findAll(); + List allVariants = variantRepo.findAll().stream() + .filter(v -> Boolean.TRUE.equals(v.getIsActive())) + .sorted(Comparator + .comparing((FilamentVariant v) -> safeMaterialCode(v.getFilamentMaterialType()), String.CASE_INSENSITIVE_ORDER) + .thenComparing(v -> safeString(v.getVariantDisplayName()), String.CASE_INSENSITIVE_ORDER)) + .toList(); + + Set compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter); List materialOptions = types.stream() + .sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER)) .map(type -> { + if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) { + return null; + } + List variants = allVariants.stream() - .filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive()) + .filter(v -> v.getFilamentMaterialType() != null + && v.getFilamentMaterialType().getId().equals(type.getId())) .map(v -> new OptionsResponse.VariantOption( + v.getId(), v.getVariantDisplayName(), v.getColorName(), - getColorHex(v.getColorName()), // Need helper or store hex in DB - v.getStockSpools().doubleValue() <= 0 + resolveHexColor(v), + v.getFinishType() != null ? v.getFinishType() : "GLOSSY", + v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, + toStockFilamentGrams(v), + v.getStockSpools() == null || v.getStockSpools().doubleValue() <= 0 )) .collect(Collectors.toList()); - // Only include material if it has active variants - if (variants.isEmpty()) return null; + if (variants.isEmpty()) { + return null; + } return new OptionsResponse.MaterialOption( type.getMaterialCode(), - type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"), + type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"), variants ); }) .filter(m -> m != null) - .collect(Collectors.toList()); + .toList(); - // 2. Qualities (Static as per user request) List qualities = List.of( new OptionsResponse.QualityOption("draft", "Draft"), new OptionsResponse.QualityOption("standard", "Standard"), new OptionsResponse.QualityOption("extra_fine", "High Definition") ); - // 3. Infill Patterns (Static as per user request) List patterns = List.of( new OptionsResponse.InfillPatternOption("grid", "Grid"), new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); - // 4. Layer Heights List layers = layerHeightRepo.findAll().stream() - .filter(l -> l.getIsActive()) + .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()) )) - .collect(Collectors.toList()); + .toList(); - // 5. Nozzles List nozzles = nozzleRepo.findAll().stream() - .filter(n -> n.getIsActive()) + .filter(n -> Boolean.TRUE.equals(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)") + n.getExtraNozzleChangeFeeChf().doubleValue() > 0 + ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) + : " (Standard)") )) - .collect(Collectors.toList()); + .toList(); return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); } - // Temporary helper until we add hex to DB + private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { + PrinterMachine machine = null; + if (printerMachineId != null) { + machine = printerMachineRepo.findById(printerMachineId).orElse(null); + } + if (machine == null) { + machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); + } + if (machine == null) { + return Set.of(); + } + + BigDecimal nozzle = nozzleDiameter != null + ? BigDecimal.valueOf(nozzleDiameter) + : BigDecimal.valueOf(0.40); + + PrinterMachineProfile machineProfile = orcaProfileResolver + .resolveMachineProfile(machine, nozzle) + .orElse(null); + + if (machineProfile == null) { + return Set.of(); + } + + List maps = materialOrcaMapRepo.findByPrinterMachineProfileAndIsActiveTrue(machineProfile); + return maps.stream() + .map(MaterialOrcaProfileMap::getFilamentMaterialType) + .filter(m -> m != null && m.getId() != null) + .map(FilamentMaterialType::getId) + .collect(Collectors.toSet()); + } + + private String resolveHexColor(FilamentVariant variant) { + if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { + return variant.getColorHex(); + } + return getColorHex(variant.getColorName()); + } + + private double toStockFilamentGrams(FilamentVariant variant) { + if (variant.getStockSpools() == null || variant.getSpoolNetKg() == null) { + return 0d; + } + return variant.getStockSpools() + .multiply(variant.getSpoolNetKg()) + .multiply(BigDecimal.valueOf(1000)) + .doubleValue(); + } + + private String safeMaterialCode(FilamentMaterialType type) { + if (type == null || type.getMaterialCode() == null) { + return ""; + } + return type.getMaterialCode(); + } + + private String safeString(String value) { + return value == null ? "" : value; + } + + // Temporary helper for legacy values where color hex is not yet set in DB private String getColorHex(String colorName) { + if (colorName == null) { + return "#9e9e9e"; + } String lower = colorName.toLowerCase(); if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a"; if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5"; @@ -120,6 +217,6 @@ public class OptionsController { } if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2"; if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d"; - return "#9e9e9e"; // Default grey + return "#9e9e9e"; } } diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 47ea7a8..910fa94 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -1,14 +1,19 @@ package com.printcalculator.controller; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; import com.printcalculator.model.ModelDimensions; import com.printcalculator.model.PrintStats; import com.printcalculator.model.QuoteResult; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.SlicerService; import org.springframework.http.MediaType; @@ -43,18 +48,20 @@ public class QuoteSessionController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + private final OrcaProfileResolver orcaProfileResolver; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.service.ClamAVService clamAVService; - // Defaults - private static final String DEFAULT_FILAMENT = "pla_basic"; - private static final String DEFAULT_PROCESS = "standard"; - public QuoteSessionController(QuoteSessionRepository sessionRepo, QuoteLineItemRepository lineItemRepo, SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, + FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo, + OrcaProfileResolver orcaProfileResolver, com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.service.ClamAVService clamAVService) { this.sessionRepo = sessionRepo; @@ -62,6 +69,9 @@ public class QuoteSessionController { this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + this.orcaProfileResolver = orcaProfileResolver; this.pricingRepo = pricingRepo; this.clamAVService = clamAVService; } @@ -129,45 +139,31 @@ public class QuoteSessionController { // Apply Basic/Advanced Logic applyPrintSettings(settings); + BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); + + // Pick machine (selected machine if provided, otherwise first active) + PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); + + // Resolve selected filament variant + FilamentVariant selectedVariant = resolveFilamentVariant(settings); + // Update session global settings from the most recent item added - session.setMaterialCode(settings.getMaterial()); - session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4)); + session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); + session.setNozzleDiameterMm(nozzleDiameter); session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); session.setInfillPattern(settings.getInfillPattern()); session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); sessionRepo.save(session); - // REAL SLICING - // 1. Pick Machine (default to first active or specific) - PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() - .orElseThrow(() -> new RuntimeException("No active printer found")); - - // 2. Pick Profiles - String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" - // If the display name doesn't match the json profile name, we might need a mapping key in DB. - // For now assuming display name works or we use a tough default - machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists. - // Ideally: machine.getSlicerProfileName(); - - String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA"); - // Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG" - if (settings.getMaterial() != null) { - if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA"; - else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG"; - else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU"; - else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS"; - } + OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); + String machineProfile = profiles.machineProfileName(); + String filamentProfile = profiles.filamentProfileName(); - String processProfile = "0.20mm Standard @BBL A1"; - // Mapping quality to process - // "standard" -> "0.20mm Standard @BBL A1" - // "draft" -> "0.28mm Extra Draft @BBL A1" - // "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca) - // Let's use robust defaults or simple overrides + String processProfile = "standard"; if (settings.getLayerHeight() != null) { - if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1"; - else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1"; + if (settings.getLayerHeight() >= 0.28) processProfile = "draft"; + else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine"; } // Build overrides map from settings @@ -189,7 +185,7 @@ public class QuoteSessionController { Optional modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile()); // 4. Calculate Quote - QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); + QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant); // 5. Create Line Item QuoteLineItem item = new QuoteLineItem(); @@ -197,7 +193,8 @@ public class QuoteSessionController { item.setOriginalFilename(file.getOriginalFilename()); item.setStoredPath(persistentPath.toString()); // SAVE PATH item.setQuantity(1); - item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); + item.setColorCode(selectedVariant.getColorName()); + item.setFilamentVariant(selectedVariant); item.setStatus("READY"); // or CALCULATED item.setPrintTimeSeconds((int) stats.printTimeSeconds()); @@ -264,6 +261,50 @@ public class QuoteSessionController { } } + private PrinterMachine resolvePrinterMachine(Long printerMachineId) { + if (printerMachineId != null) { + PrinterMachine selected = machineRepo.findById(printerMachineId) + .orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId)); + if (!Boolean.TRUE.equals(selected.getIsActive())) { + throw new RuntimeException("Selected printer machine is not active"); + } + return selected; + } + + return machineRepo.findFirstByIsActiveTrue() + .orElseThrow(() -> new RuntimeException("No active printer found")); + } + + private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) { + if (settings.getFilamentVariantId() != null) { + FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId()) + .orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId())); + if (!Boolean.TRUE.equals(variant.getIsActive())) { + throw new RuntimeException("Selected filament variant is not active"); + } + return variant; + } + + String requestedMaterialCode = settings.getMaterial() != null + ? settings.getMaterial().trim().toUpperCase() + : "PLA"; + + FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode) + .orElseGet(() -> materialRepo.findByMaterialCode("PLA") + .orElseThrow(() -> new RuntimeException("Fallback material PLA not configured"))); + + String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null; + if (requestedColor != null && !requestedColor.isBlank()) { + Optional byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor); + if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) { + return byColor.get(); + } + } + + return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) + .orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode)); + } + // 3. Update Line Item @PatchMapping("/line-items/{lineItemId}") @Transactional @@ -344,6 +385,7 @@ public class QuoteSessionController { dto.put("printTimeSeconds", item.getPrintTimeSeconds()); dto.put("materialGrams", item.getMaterialGrams()); dto.put("colorCode", item.getColorCode()); + dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); dto.put("status", item.getStatus()); BigDecimal unitPrice = item.getUnitPriceChf(); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java index cb2a5e0..2d469e6 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java @@ -8,6 +8,8 @@ import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentVariant; import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -17,8 +19,12 @@ import java.math.BigDecimal; import java.time.OffsetDateTime; import java.util.Comparator; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @@ -26,16 +32,26 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; @Transactional(readOnly = true) public class AdminFilamentController { private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Set ALLOWED_FINISH_TYPES = Set.of( + "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" + ); private final FilamentMaterialTypeRepository materialRepo; private final FilamentVariantRepository variantRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderItemRepository orderItemRepo; public AdminFilamentController( FilamentMaterialTypeRepository materialRepo, - FilamentVariantRepository variantRepo + FilamentVariantRepository variantRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderItemRepository orderItemRepo ) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderItemRepo = orderItemRepo; } @GetMapping("/materials") @@ -130,6 +146,20 @@ public class AdminFilamentController { return ResponseEntity.ok(toVariantDto(saved)); } + @DeleteMapping("/variants/{variantId}") + @Transactional + public ResponseEntity deleteVariant(@PathVariable Long variantId) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { + throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); + } + + variantRepo.delete(variant); + return ResponseEntity.noContent().build(); + } + private void applyMaterialPayload( FilamentMaterialType material, AdminUpsertFilamentMaterialTypeRequest payload, @@ -156,10 +186,17 @@ public class AdminFilamentController { String normalizedDisplayName, String normalizedColorName ) { + String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); + String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); + String normalizedBrand = normalizeOptional(payload.getBrand()); + variant.setFilamentMaterialType(material); variant.setVariantDisplayName(normalizedDisplayName); variant.setColorName(normalizedColorName); - variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte())); + variant.setColorHex(normalizedColorHex); + variant.setFinishType(normalizedFinishType); + variant.setBrand(normalizedBrand); + variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); variant.setCostChfPerKg(payload.getCostChfPerKg()); variant.setStockSpools(payload.getStockSpools()); @@ -188,6 +225,35 @@ public class AdminFilamentController { return value.trim(); } + private String normalizeAndValidateColorHex(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { + String normalized = finishType == null || finishType.isBlank() + ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") + : finishType.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_FINISH_TYPES.contains(normalized)) { + throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { if (payload == null || payload.getMaterialTypeId() == null) { throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); @@ -268,16 +334,20 @@ public class AdminFilamentController { dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setFinishType(variant.getFinishType()); + dto.setBrand(variant.getBrand()); dto.setIsMatte(variant.getIsMatte()); dto.setIsSpecial(variant.getIsSpecial()); dto.setCostChfPerKg(variant.getCostChfPerKg()); dto.setStockSpools(variant.getStockSpools()); dto.setSpoolNetKg(variant.getSpoolNetKg()); + BigDecimal stockKg = BigDecimal.ZERO; if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { - dto.setStockKg(variant.getStockSpools().multiply(variant.getSpoolNetKg())); - } else { - dto.setStockKg(BigDecimal.ZERO); + stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); } + dto.setStockKg(stockKg); + dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); dto.setIsActive(variant.getIsActive()); dto.setCreatedAt(variant.getCreatedAt()); return dto; diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java index c39b3b8..29219de 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException; import java.io.IOException; import java.io.UncheckedIOException; +import java.math.BigDecimal; import java.net.MalformedURLException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -105,6 +106,10 @@ public class AdminOperationsController { dto.setStockSpools(stock.getStockSpools()); dto.setSpoolNetKg(stock.getSpoolNetKg()); dto.setStockKg(stock.getStockKg()); + BigDecimal grams = stock.getStockKg() != null + ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) + : BigDecimal.ZERO; + dto.setStockFilamentGrams(grams); if (variant != null) { dto.setMaterialCode( diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java index 5848ca2..07c18e8 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java @@ -10,6 +10,7 @@ public class AdminFilamentStockDto { private BigDecimal stockSpools; private BigDecimal spoolNetKg; private BigDecimal stockKg; + private BigDecimal stockFilamentGrams; private Boolean active; public Long getFilamentVariantId() { @@ -68,6 +69,14 @@ public class AdminFilamentStockDto { this.stockKg = stockKg; } + public BigDecimal getStockFilamentGrams() { + return stockFilamentGrams; + } + + public void setStockFilamentGrams(BigDecimal stockFilamentGrams) { + this.stockFilamentGrams = stockFilamentGrams; + } + public Boolean getActive() { return active; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java index f8c2141..88b32ac 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java @@ -12,12 +12,16 @@ public class AdminFilamentVariantDto { private String materialTechnicalTypeLabel; private String variantDisplayName; private String colorName; + private String colorHex; + private String finishType; + private String brand; private Boolean isMatte; private Boolean isSpecial; private BigDecimal costChfPerKg; private BigDecimal stockSpools; private BigDecimal spoolNetKg; private BigDecimal stockKg; + private BigDecimal stockFilamentGrams; private Boolean isActive; private OffsetDateTime createdAt; @@ -85,6 +89,30 @@ public class AdminFilamentVariantDto { this.colorName = colorName; } + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + public Boolean getIsMatte() { return isMatte; } @@ -133,6 +161,14 @@ public class AdminFilamentVariantDto { this.stockKg = stockKg; } + public BigDecimal getStockFilamentGrams() { + return stockFilamentGrams; + } + + public void setStockFilamentGrams(BigDecimal stockFilamentGrams) { + this.stockFilamentGrams = stockFilamentGrams; + } + public Boolean getIsActive() { return isActive; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java index 97763be..89cd51c 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java @@ -6,6 +6,9 @@ public class AdminUpsertFilamentVariantRequest { private Long materialTypeId; private String variantDisplayName; private String colorName; + private String colorHex; + private String finishType; + private String brand; private Boolean isMatte; private Boolean isSpecial; private BigDecimal costChfPerKg; @@ -37,6 +40,30 @@ public class AdminUpsertFilamentVariantRequest { this.colorName = colorName; } + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + public Boolean getIsMatte() { return isMatte; } diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index dc60fb7..54d7e87 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -10,7 +10,16 @@ public record OptionsResponse( List nozzleDiameters ) { public record MaterialOption(String code, String label, List variants) {} - public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {} + public record VariantOption( + Long id, + String name, + String colorName, + String hexColor, + String finishType, + Double stockSpools, + Double stockFilamentGrams, + boolean isOutOfStock + ) {} public record QualityOption(String id, String label) {} public record InfillPatternOption(String id, String label) {} public record LayerHeightOptionDTO(double value, String label) {} diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java index 41e62c5..5c56ced 100644 --- a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -10,6 +10,8 @@ public class PrintSettingsDto { // Common private String material; // e.g. "PLA", "PETG" private String color; // e.g. "White", "#FFFFFF" + private Long filamentVariantId; + private Long printerMachineId; // Basic Mode private String quality; // "draft", "standard", "high" diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java index 22c9003..e2f8bf5 100644 --- a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java @@ -24,6 +24,16 @@ public class FilamentVariant { @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) private String colorName; + @Column(name = "color_hex", length = Integer.MAX_VALUE) + private String colorHex; + + @ColumnDefault("'GLOSSY'") + @Column(name = "finish_type", length = Integer.MAX_VALUE) + private String finishType; + + @Column(name = "brand", length = Integer.MAX_VALUE) + private String brand; + @ColumnDefault("false") @Column(name = "is_matte", nullable = false) private Boolean isMatte; @@ -83,6 +93,30 @@ public class FilamentVariant { this.colorName = colorName; } + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + public Boolean getIsMatte() { return isMatte; } @@ -139,4 +173,4 @@ public class FilamentVariant { this.createdAt = createdAt; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java new file mode 100644 index 0000000..bd85413 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java @@ -0,0 +1,72 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "filament_variant_orca_override", uniqueConstraints = { + @UniqueConstraint(name = "ux_filament_variant_orca_override_variant_machine", columnNames = { + "filament_variant_id", "printer_machine_profile_id" + }) +}) +public class FilamentVariantOrcaOverride { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "filament_variant_orca_override_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "filament_variant_id", nullable = false) + private FilamentVariant filamentVariant; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_profile_id", nullable = false) + private PrinterMachineProfile printerMachineProfile; + + @Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaFilamentProfileName; + + @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 FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + + public PrinterMachineProfile getPrinterMachineProfile() { + return printerMachineProfile; + } + + public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) { + this.printerMachineProfile = printerMachineProfile; + } + + public String getOrcaFilamentProfileName() { + return orcaFilamentProfileName; + } + + public void setOrcaFilamentProfileName(String orcaFilamentProfileName) { + this.orcaFilamentProfileName = orcaFilamentProfileName; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java b/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java new file mode 100644 index 0000000..b162929 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java @@ -0,0 +1,72 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "material_orca_profile_map", uniqueConstraints = { + @UniqueConstraint(name = "ux_material_orca_profile_map_machine_material", columnNames = { + "printer_machine_profile_id", "filament_material_type_id" + }) +}) +public class MaterialOrcaProfileMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "material_orca_profile_map_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_profile_id", nullable = false) + private PrinterMachineProfile printerMachineProfile; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "filament_material_type_id", nullable = false) + private FilamentMaterialType filamentMaterialType; + + @Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaFilamentProfileName; + + @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 PrinterMachineProfile getPrinterMachineProfile() { + return printerMachineProfile; + } + + public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) { + this.printerMachineProfile = printerMachineProfile; + } + + public FilamentMaterialType getFilamentMaterialType() { + return filamentMaterialType; + } + + public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) { + this.filamentMaterialType = filamentMaterialType; + } + + public String getOrcaFilamentProfileName() { + return orcaFilamentProfileName; + } + + public void setOrcaFilamentProfileName(String orcaFilamentProfileName) { + this.orcaFilamentProfileName = orcaFilamentProfileName; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index a71fa5a..e5d6f65 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -44,6 +44,10 @@ public class OrderItem { @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) private String materialCode; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "filament_variant_id") + private FilamentVariant filamentVariant; + @Column(name = "color_code", length = Integer.MAX_VALUE) private String colorCode; @@ -158,6 +162,14 @@ public class OrderItem { this.materialCode = materialCode; } + public FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + public String getColorCode() { return colorCode; } diff --git a/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java b/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java new file mode 100644 index 0000000..a600885 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java @@ -0,0 +1,85 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Table(name = "printer_machine_profile", uniqueConstraints = { + @UniqueConstraint(name = "ux_printer_machine_profile_machine_nozzle", columnNames = { + "printer_machine_id", "nozzle_diameter_mm" + }) +}) +public class PrinterMachineProfile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "printer_machine_profile_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_id", nullable = false) + private PrinterMachine printerMachine; + + @Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2) + private BigDecimal nozzleDiameterMm; + + @Column(name = "orca_machine_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaMachineProfileName; + + @ColumnDefault("false") + @Column(name = "is_default", nullable = false) + private Boolean isDefault; + + @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 PrinterMachine getPrinterMachine() { + return printerMachine; + } + + public void setPrinterMachine(PrinterMachine printerMachine) { + this.printerMachine = printerMachine; + } + + public BigDecimal getNozzleDiameterMm() { + return nozzleDiameterMm; + } + + public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { + this.nozzleDiameterMm = nozzleDiameterMm; + } + + public String getOrcaMachineProfileName() { + return orcaMachineProfileName; + } + + public void setOrcaMachineProfileName(String orcaMachineProfileName) { + this.orcaMachineProfileName = orcaMachineProfileName; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean isDefault) { + this.isDefault = isDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index c29260c..321c705 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -40,6 +40,11 @@ public class QuoteLineItem { @Column(name = "color_code", length = Integer.MAX_VALUE) private String colorCode; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "filament_variant_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private FilamentVariant filamentVariant; + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) private BigDecimal boundingBoxXMm; @@ -124,6 +129,14 @@ public class QuoteLineItem { this.colorCode = colorCode; } + public FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + public BigDecimal getBoundingBoxXMm() { return boundingBoxXMm; } @@ -212,4 +225,4 @@ public class QuoteLineItem { this.updatedAt = updatedAt; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java new file mode 100644 index 0000000..cacd823 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java @@ -0,0 +1,15 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantOrcaOverride; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FilamentVariantOrcaOverrideRepository extends JpaRepository { + Optional findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue( + FilamentVariant filamentVariant, + PrinterMachineProfile printerMachineProfile + ); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java b/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java new file mode 100644 index 0000000..0c2e61c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java @@ -0,0 +1,18 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.MaterialOrcaProfileMap; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MaterialOrcaProfileMapRepository extends JpaRepository { + Optional findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue( + PrinterMachineProfile printerMachineProfile, + FilamentMaterialType filamentMaterialType + ); + + List findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile); +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java index 0068809..3503bb2 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface OrderItemRepository extends JpaRepository { List findByOrder_Id(UUID orderId); -} \ No newline at end of file + boolean existsByFilamentVariant_Id(Long filamentVariantId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java b/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java new file mode 100644 index 0000000..4213e25 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java @@ -0,0 +1,15 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +public interface PrinterMachineProfileRepository extends JpaRepository { + Optional findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm); + Optional findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine); + List findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine); +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 809f33b..7d39175 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface QuoteLineItemRepository extends JpaRepository { List findByQuoteSessionId(UUID quoteSessionId); -} \ No newline at end of file + boolean existsByFilamentVariant_Id(Long filamentVariantId); +} diff --git a/backend/src/main/java/com/printcalculator/service/OrcaProfileResolver.java b/backend/src/main/java/com/printcalculator/service/OrcaProfileResolver.java new file mode 100644 index 0000000..137c97b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/OrcaProfileResolver.java @@ -0,0 +1,139 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantOrcaOverride; +import com.printcalculator.entity.MaterialOrcaProfileMap; +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.PrinterMachineProfile; +import com.printcalculator.repository.FilamentVariantOrcaOverrideRepository; +import com.printcalculator.repository.MaterialOrcaProfileMapRepository; +import com.printcalculator.repository.PrinterMachineProfileRepository; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Optional; + +@Service +public class OrcaProfileResolver { + + private final PrinterMachineProfileRepository machineProfileRepo; + private final MaterialOrcaProfileMapRepository materialMapRepo; + private final FilamentVariantOrcaOverrideRepository variantOverrideRepo; + + public OrcaProfileResolver( + PrinterMachineProfileRepository machineProfileRepo, + MaterialOrcaProfileMapRepository materialMapRepo, + FilamentVariantOrcaOverrideRepository variantOverrideRepo + ) { + this.machineProfileRepo = machineProfileRepo; + this.materialMapRepo = materialMapRepo; + this.variantOverrideRepo = variantOverrideRepo; + } + + public ResolvedProfiles resolve(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm, FilamentVariant variant) { + Optional machineProfileOpt = resolveMachineProfile(printerMachine, nozzleDiameterMm); + + String machineProfileName = machineProfileOpt + .map(PrinterMachineProfile::getOrcaMachineProfileName) + .orElseGet(() -> fallbackMachineProfile(printerMachine, nozzleDiameterMm)); + + String filamentProfileName = machineProfileOpt + .map(machineProfile -> resolveFilamentProfileWithMachineProfile(machineProfile, variant) + .orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType()))) + .orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType())); + + return new ResolvedProfiles(machineProfileName, filamentProfileName, machineProfileOpt.orElse(null)); + } + + public Optional resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) { + if (machine == null) { + return Optional.empty(); + } + + BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm); + if (normalizedNozzle != null) { + Optional exact = machineProfileRepo + .findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle); + if (exact.isPresent()) { + return exact; + } + } + + Optional defaultProfile = machineProfileRepo + .findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine); + if (defaultProfile.isPresent()) { + return defaultProfile; + } + + return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine) + .stream() + .findFirst(); + } + + private Optional resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) { + if (machineProfile == null || variant == null) { + return Optional.empty(); + } + + Optional override = variantOverrideRepo + .findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile); + + if (override.isPresent()) { + return Optional.ofNullable(override.get().getOrcaFilamentProfileName()); + } + + Optional map = materialMapRepo + .findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue( + machineProfile, + variant.getFilamentMaterialType() + ); + + return map.map(MaterialOrcaProfileMap::getOrcaFilamentProfileName); + } + + private String fallbackMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) { + if (machine == null || machine.getPrinterDisplayName() == null || machine.getPrinterDisplayName().isBlank()) { + return "Bambu Lab A1 0.4 nozzle"; + } + + String displayName = machine.getPrinterDisplayName(); + if (displayName.toLowerCase().contains("bambulab a1") || displayName.toLowerCase().contains("bambu lab a1")) { + BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm); + if (normalizedNozzle == null) { + return "Bambu Lab A1 0.4 nozzle"; + } + return "Bambu Lab A1 " + normalizedNozzle.toPlainString() + " nozzle"; + } + + return displayName; + } + + private String fallbackFilamentProfile(FilamentMaterialType materialType) { + String materialCode = materialType != null && materialType.getMaterialCode() != null + ? materialType.getMaterialCode().trim().toUpperCase() + : "PLA"; + + return switch (materialCode) { + case "PETG" -> "Generic PETG"; + case "TPU" -> "Generic TPU"; + case "PC" -> "Generic PC"; + case "ABS" -> "Generic ABS"; + default -> "Generic PLA"; + }; + } + + private BigDecimal normalizeNozzle(BigDecimal nozzleDiameterMm) { + if (nozzleDiameterMm == null) { + return null; + } + return nozzleDiameterMm.setScale(2, RoundingMode.HALF_UP); + } + + public record ResolvedProfiles( + String machineProfileName, + String filamentProfileName, + PrinterMachineProfile machineProfile + ) {} +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 2b951da..6ed8884 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -203,7 +203,14 @@ public class OrderService { oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setQuantity(qItem.getQuantity()); oItem.setColorCode(qItem.getColorCode()); - oItem.setMaterialCode(session.getMaterialCode()); + oItem.setFilamentVariant(qItem.getFilamentVariant()); + if (qItem.getFilamentVariant() != null + && qItem.getFilamentVariant().getFilamentMaterialType() != null + && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { + oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); + } else { + oItem.setMaterialCode(session.getMaterialCode()); + } BigDecimal distributedUnitPrice = qItem.getUnitPriceChf(); if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index 1ffe379..04fb5ea 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -60,40 +60,49 @@ public class QuoteCalculator { .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)); + return calculate(stats, machine, policy, variant); + } - // --- CALCULATIONS --- + public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) { + PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + if (policy == null) { + throw new RuntimeException("No active pricing policy found"); + } - // Material Cost: (weight / 1000) * costPerKg - BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); + PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null); + if (machine == null) { + machine = machineRepo.findFirstByIsActiveTrue() + .orElseThrow(() -> new RuntimeException("No active printer found")); + } + + return calculate(stats, machine, policy, variant); + } + + private QuoteResult calculate(PrintStats stats, PrinterMachine machine, PricingPolicy policy, FilamentVariant variant) { + BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()) + .divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); - // We do NOT add tiered machine cost here anymore - it is calculated globally per session - BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()) + .divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - // Energy Cost: (watts / 1000) * hours * costPerKwh - BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); + 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 (Costs without Fixed Fees and without Machine Tiers) BigDecimal subtotal = materialCost.add(energyCost); - - // Markup - // Markup is percentage (e.g. 20.0) - BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); + BigDecimal markupFactor = BigDecimal.ONE.add( + policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP) + ); subtotal = subtotal.multiply(markupFactor); + return new QuoteResult(subtotal.doubleValue(), "CHF", stats); } public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) { diff --git a/db.sql b/db.sql index 339f027..ee9422d 100644 --- a/db.sql +++ b/db.sql @@ -44,6 +44,10 @@ create table filament_variant variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" color_name text not null, -- Nero, Bianco, ecc. + color_hex text, + finish_type text not null default 'GLOSSY' + check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), + brand text, is_matte boolean not null default false, is_special boolean not null default false, @@ -66,6 +70,37 @@ select filament_variant_id, (stock_spools * spool_net_kg) as stock_kg from filament_variant; +create table printer_machine_profile +( + printer_machine_profile_id bigserial primary key, + printer_machine_id bigint not null references printer_machine (printer_machine_id) on delete cascade, + nozzle_diameter_mm numeric(4, 2) not null check (nozzle_diameter_mm > 0), + orca_machine_profile_name text not null, + is_default boolean not null default false, + is_active boolean not null default true, + unique (printer_machine_id, nozzle_diameter_mm) +); + +create table material_orca_profile_map +( + material_orca_profile_map_id bigserial primary key, + printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, + filament_material_type_id bigint not null references filament_material_type (filament_material_type_id), + orca_filament_profile_name text not null, + is_active boolean not null default true, + unique (printer_machine_profile_id, filament_material_type_id) +); + +create table filament_variant_orca_override +( + filament_variant_orca_override_id bigserial primary key, + filament_variant_id bigint not null references filament_variant (filament_variant_id) on delete cascade, + printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, + orca_filament_profile_name text not null, + is_active boolean not null default true, + unique (filament_variant_id, printer_machine_profile_id) +); + create table pricing_policy @@ -252,6 +287,7 @@ insert into filament_material_type (material_code, values ('PLA', false, false, null), ('PETG', false, false, null), ('TPU', true, false, null), + ('PC', false, true, 'engineering'), ('ABS', false, false, null), ('Nylon', false, false, null), ('Carbon PLA', false, false, null) @@ -275,6 +311,9 @@ insert into filament_variant (filament_material_type_id, variant_display_name, color_name, + color_hex, + finish_type, + brand, is_matte, is_special, cost_chf_per_kg, @@ -284,6 +323,9 @@ into filament_variant (filament_material_type_id, select pla.filament_material_type_id, v.variant_display_name, v.color_name, + v.color_hex, + v.finish_type, + null::text as brand, v.is_matte, v.is_special, 18.00, -- PLA da Excel @@ -291,17 +333,114 @@ select pla.filament_material_type_id, 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) + cross join (values ('PLA Bianco', 'Bianco', '#F5F5F5', 'GLOSSY', false, false, 3.000::numeric), + ('PLA Nero', 'Nero', '#1A1A1A', 'GLOSSY', false, false, 3.000::numeric), + ('PLA Blu', 'Blu', '#1976D2', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Arancione', 'Arancione', '#FFA726', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Grigio', 'Grigio', '#BDBDBD', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Grigio Scuro', 'Grigio scuro', '#424242', 'MATTE', true, false, 1.000::numeric), + ('PLA Grigio Chiaro', 'Grigio chiaro', '#D6D6D6', 'MATTE', true, false, 1.000::numeric), + ('PLA Viola', 'Viola', '#7B1FA2', 'GLOSSY', false, false, + 1.000::numeric)) as v(variant_display_name, color_name, color_hex, finish_type, is_matte, is_special, stock_spools) on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + 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; + +-- Varianti base per materiali principali del calcolatore +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'PETG') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'PETG Nero', + 'Nero', + '#1A1A1A', + 'GLOSSY', + 'Bambu', + false, + false, + 24.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + 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; + +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'TPU') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'TPU Nero', + 'Nero', + '#1A1A1A', + 'GLOSSY', + 'Bambu', + false, + false, + 42.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + 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; + +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'PC') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'PC Naturale', + 'Naturale', + '#D9D9D9', + 'TRANSLUCENT', + 'Generic', + false, + true, + 48.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, is_matte = excluded.is_matte, is_special = excluded.is_special, cost_chf_per_kg = excluded.cost_chf_per_kg, @@ -325,6 +464,51 @@ on conflict (nozzle_diameter_mm) do update extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf, is_active = excluded.is_active; +-- ========================================================= +-- 5b) Orca machine/material mapping (data-driven) +-- ========================================================= +with a1 as (select printer_machine_id + from printer_machine + where printer_display_name = 'BambuLab A1') +insert +into printer_machine_profile (printer_machine_id, nozzle_diameter_mm, orca_machine_profile_name, is_default, is_active) +select a1.printer_machine_id, v.nozzle_diameter_mm, v.profile_name, v.is_default, true +from a1 + cross join (values (0.40::numeric, 'Bambu Lab A1 0.4 nozzle', true), + (0.20::numeric, 'Bambu Lab A1 0.2 nozzle', false), + (0.60::numeric, 'Bambu Lab A1 0.6 nozzle', false), + (0.80::numeric, 'Bambu Lab A1 0.8 nozzle', false)) + as v(nozzle_diameter_mm, profile_name, is_default) +on conflict (printer_machine_id, nozzle_diameter_mm) do update + set orca_machine_profile_name = excluded.orca_machine_profile_name, + is_default = excluded.is_default, + is_active = excluded.is_active; + +with p as (select printer_machine_profile_id + from printer_machine_profile pmp + join printer_machine pm on pm.printer_machine_id = pmp.printer_machine_id + where pm.printer_display_name = 'BambuLab A1' + and pmp.nozzle_diameter_mm = 0.40::numeric), + m as (select filament_material_type_id, material_code + from filament_material_type + where material_code in ('PLA', 'PETG', 'TPU', 'PC')) +insert +into material_orca_profile_map (printer_machine_profile_id, filament_material_type_id, orca_filament_profile_name, is_active) +select p.printer_machine_profile_id, + m.filament_material_type_id, + case m.material_code + when 'PLA' then 'Bambu PLA Basic @BBL A1' + when 'PETG' then 'Bambu PETG Basic @BBL A1' + when 'TPU' then 'Bambu TPU 95A @BBL A1' + when 'PC' then 'Generic PC @BBL A1' + end, + true +from p + cross join m +on conflict (printer_machine_profile_id, filament_material_type_id) do update + set orca_filament_profile_name = excluded.orca_filament_profile_name, + is_active = excluded.is_active; + -- ========================================================= -- 6) Layer heights (opzioni) @@ -420,6 +604,7 @@ CREATE TABLE IF NOT EXISTS quote_line_items original_filename text NOT NULL, quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), color_code text, -- es: white/black o codice interno + filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), -- Output slicing / calcolo bounding_box_x_mm numeric(10, 3), @@ -529,6 +714,7 @@ CREATE TABLE IF NOT EXISTS order_items sha256_hex text, -- opzionale, utile anche per dedup interno material_code text NOT NULL, + filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), color_code text, quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index 7dde6f2..e4f6105 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -2,6 +2,7 @@ export interface ColorOption { label: string; value: string; hex: string; + variantId?: number; outOfStock?: boolean; } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html index c1abfb7..051e71d 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -14,48 +14,171 @@
-

Inserimento rapido

-
-
-

Nuovo materiale

-
- - +
+

Inserimento rapido

+ +
+ +
+
+
+

Nuovo materiale

+
+ + +
+ +
+ + +
+ + +
+ +
+

Nuova variante

+
+ + + + + + + + + +
+ +
+ + + +
+ +

+ Stock spools: {{ newVariant.stockSpools | number:'1.0-3' }} | + Filamento totale: {{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g +

+ + +
+
+
+
+ +
+

Varianti filamento

+
+
+
+ + +
+ {{ variant.variantDisplayName }} +
+ + + {{ variant.colorName || 'N/D' }} + + Stock spools: {{ variant.stockSpools | number:'1.0-3' }} + Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g +
+
+ +
+ Stock basso + Stock ok + +
-
- - -
- - -
- -
-

Nuova variante

-
+
+ + +
-
+
-

- Stock stimato: {{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg +

+ Stock spools: {{ variant.stockSpools | number:'1.0-3' }} | + Filamento totale: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g

- -
+
+

Nessuna variante configurata.

@@ -150,74 +298,6 @@

Nessun materiale configurato.

- -
-

Varianti filamento

-
-
-
- {{ variant.variantDisplayName }} - Stock basso - Stock ok -
- -
- - - - - - -
- -
- - - -
- -

- Totale stimato: {{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg -

- - -
-
-

Nessuna variante configurata.

-
@@ -228,3 +308,24 @@

Sezione collassata ({{ materials.length }} materiali).

+ + +

Sezione collassata.

+
+ +
+
+

Sei sicuro?

+

Vuoi eliminare la variante {{ variantToDelete?.variantDisplayName }}?

+

L'operazione non è reversibile.

+
+ + +
+
diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss index 43c1b29..822bea9 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -141,24 +141,30 @@ select:disabled { } .material-grid, -.variant-grid { +.variant-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--space-3); } .material-card, -.variant-card { +.variant-row { border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-neutral-100); padding: var(--space-3); } +.material-grid { + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); +} + +.variant-list { + grid-template-columns: 1fr; +} + .variant-header { display: flex; - justify-content: space-between; - align-items: center; + align-items: flex-start; gap: var(--space-2); margin-bottom: var(--space-3); } @@ -167,6 +173,57 @@ select:disabled { font-size: 1rem; } +.variant-head-main { + display: grid; + gap: var(--space-1); + flex: 1; + min-width: 0; +} + +.variant-collapsed-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + color: var(--color-text-muted); + font-size: 0.92rem; +} + +.color-summary { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-dot { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--color-border); + display: inline-block; +} + +.variant-head-actions { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.expand-toggle { + min-width: 34px; + height: 34px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + color: var(--color-text); + font-size: 1rem; + line-height: 1; +} + +.expand-toggle:hover:not(:disabled) { + background: var(--color-neutral-100); +} + .variant-meta { margin: 0 0 var(--space-3); font-size: 0.9rem; @@ -203,6 +260,25 @@ button:disabled { background: var(--color-neutral-100); } +.btn-delete { + background: #dc3545; + color: #ffffff; +} + +.btn-delete:hover:not(:disabled) { + background: #bb2d3b; +} + +.btn-secondary { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-neutral-100); +} + .badge { display: inline-block; border-radius: 999px; @@ -236,6 +312,43 @@ button:disabled { color: var(--color-text-muted); } +.dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.32); + z-index: 1100; +} + +.confirm-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(460px, calc(100vw - 2rem)); + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--space-4); + z-index: 1101; + display: grid; + gap: var(--space-3); +} + +.confirm-dialog h4 { + margin: 0; +} + +.confirm-dialog p { + margin: 0; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + @media (max-width: 1080px) { .create-grid { grid-template-columns: 1fr; diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts index 8d70638..f79f238 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts @@ -9,6 +9,7 @@ import { AdminUpsertFilamentVariantPayload } from '../services/admin-operations.service'; import { forkJoin } from 'rxjs'; +import { getColorHex } from '../../../core/constants/colors.const'; @Component({ selector: 'app-admin-filament-stock', @@ -23,11 +24,15 @@ export class AdminFilamentStockComponent implements OnInit { materials: AdminFilamentMaterialType[] = []; variants: AdminFilamentVariant[] = []; loading = false; + quickInsertCollapsed = false; materialsCollapsed = true; creatingMaterial = false; creatingVariant = false; savingMaterialIds = new Set(); savingVariantIds = new Set(); + deletingVariantIds = new Set(); + expandedVariantIds = new Set(); + variantToDelete: AdminFilamentVariant | null = null; errorMessage: string | null = null; successMessage: string | null = null; @@ -42,6 +47,9 @@ export class AdminFilamentStockComponent implements OnInit { materialTypeId: 0, variantDisplayName: '', colorName: '', + colorHex: '', + finishType: 'GLOSSY', + brand: '', isMatte: false, isSpecial: false, costChfPerKg: 0, @@ -66,6 +74,12 @@ export class AdminFilamentStockComponent implements OnInit { next: ({ materials, variants }) => { this.materials = this.sortMaterials(materials); this.variants = this.sortVariants(variants); + const existingIds = new Set(this.variants.map(v => v.id)); + this.expandedVariantIds.forEach(id => { + if (!existingIds.has(id)) { + this.expandedVariantIds.delete(id); + } + }); if (!this.newVariant.materialTypeId && this.materials.length > 0) { this.newVariant.materialTypeId = this.materials[0].id; } @@ -178,6 +192,9 @@ export class AdminFilamentStockComponent implements OnInit { materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0, variantDisplayName: '', colorName: '', + colorHex: '', + finishType: 'GLOSSY', + brand: '', isMatte: false, isSpecial: false, costChfPerKg: 0, @@ -221,7 +238,7 @@ export class AdminFilamentStockComponent implements OnInit { } isLowStock(variant: AdminFilamentVariant): boolean { - return this.computeStockKg(variant.stockSpools, variant.spoolNetKg) < 1; + return this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) < 1000; } computeStockKg(stockSpools?: number, spoolNetKg?: number): number { @@ -234,19 +251,82 @@ export class AdminFilamentStockComponent implements OnInit { return spools * netKg; } + computeStockFilamentGrams(stockSpools?: number, spoolNetKg?: number): number { + return this.computeStockKg(stockSpools, spoolNetKg) * 1000; + } + trackById(index: number, item: { id: number }): number { return item.id; } + isVariantExpanded(variantId: number): boolean { + return this.expandedVariantIds.has(variantId); + } + + toggleVariantExpanded(variantId: number): void { + if (this.expandedVariantIds.has(variantId)) { + this.expandedVariantIds.delete(variantId); + return; + } + this.expandedVariantIds.add(variantId); + } + + getVariantColorHex(variant: AdminFilamentVariant): string { + if (variant.colorHex && variant.colorHex.trim().length > 0) { + return variant.colorHex; + } + return getColorHex(variant.colorName || ''); + } + + openDeleteVariant(variant: AdminFilamentVariant): void { + this.variantToDelete = variant; + } + + closeDeleteVariantDialog(): void { + this.variantToDelete = null; + } + + confirmDeleteVariant(): void { + const variant = this.variantToDelete; + if (!variant || this.deletingVariantIds.has(variant.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.deletingVariantIds.add(variant.id); + + this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({ + next: () => { + this.variants = this.variants.filter(v => v.id !== variant.id); + this.expandedVariantIds.delete(variant.id); + this.deletingVariantIds.delete(variant.id); + this.variantToDelete = null; + this.successMessage = 'Variante eliminata.'; + }, + error: (err) => { + this.deletingVariantIds.delete(variant.id); + this.errorMessage = this.extractErrorMessage(err, 'Eliminazione variante non riuscita.'); + } + }); + } + toggleMaterialsCollapsed(): void { this.materialsCollapsed = !this.materialsCollapsed; } + toggleQuickInsertCollapsed(): void { + this.quickInsertCollapsed = !this.quickInsertCollapsed; + } + private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload { return { materialTypeId: Number(source.materialTypeId), variantDisplayName: (source.variantDisplayName || '').trim(), colorName: (source.colorName || '').trim(), + colorHex: (source.colorHex || '').trim() || undefined, + finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(), + brand: (source.brand || '').trim() || undefined, isMatte: !!source.isMatte, isSpecial: !!source.isSpecial, costChfPerKg: Number(source.costChfPerKg ?? 0), diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts index 054c4e5..a693732 100644 --- a/frontend/src/app/features/admin/services/admin-operations.service.ts +++ b/frontend/src/app/features/admin/services/admin-operations.service.ts @@ -11,6 +11,7 @@ export interface AdminFilamentStockRow { stockSpools: number; spoolNetKg: number; stockKg: number; + stockFilamentGrams: number; active: boolean; } @@ -31,12 +32,16 @@ export interface AdminFilamentVariant { materialTechnicalTypeLabel?: string; variantDisplayName: string; colorName: string; + colorHex?: string; + finishType?: string; + brand?: string; isMatte: boolean; isSpecial: boolean; costChfPerKg: number; stockSpools: number; spoolNetKg: number; stockKg: number; + stockFilamentGrams: number; isActive: boolean; createdAt: string; } @@ -52,6 +57,9 @@ export interface AdminUpsertFilamentVariantPayload { materialTypeId: number; variantDisplayName: string; colorName: string; + colorHex?: string; + finishType?: string; + brand?: string; isMatte: boolean; isSpecial: boolean; costChfPerKg: number; @@ -167,6 +175,10 @@ export class AdminOperationsService { return this.http.put(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true }); } + deleteFilamentVariant(variantId: number): Observable { + return this.http.delete(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true }); + } + getContactRequests(): Observable { return this.http.get(`${this.baseUrl}/contact-requests`, { withCredentials: true }); } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 718ab03..d8b1dbb 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -135,7 +135,10 @@ export class CalculatorPageComponent implements OnInit { // Assuming index matches. // Need to be careful if items order changed, but usually ID sort or insert order. if (item.colorCode) { - this.uploadForm.updateItemColor(index, item.colorCode); + this.uploadForm.updateItemColor(index, { + colorName: item.colorCode, + filamentVariantId: item.filamentVariantId + }); } }); } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index bc8da94..1681515 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -54,6 +54,7 @@ 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 c1f0ed2..9bacf3f 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 @@ -15,6 +15,7 @@ interface FormItem { file: File; quantity: number; color: string; + filamentVariantId?: number; } @Component({ @@ -58,6 +59,7 @@ export class UploadFormComponent implements OnInit { if (matCode && this.fullMaterialOptions.length > 0) { const found = this.fullMaterialOptions.find(m => m.code === matCode); this.currentMaterialVariants.set(found ? found.variants : []); + this.syncItemVariantSelections(); } else { this.currentMaterialVariants.set([]); } @@ -166,8 +168,13 @@ export class UploadFormComponent implements OnInit { if (file.size > MAX_SIZE) { hasError = true; } else { - // Default color is Black - validItems.push({ file, quantity: 1, color: 'Black' }); + const defaultSelection = this.getDefaultVariantSelection(); + validItems.push({ + file, + quantity: 1, + color: defaultSelection.colorName, + filamentVariantId: defaultSelection.filamentVariantId + }); } } @@ -220,7 +227,9 @@ export class UploadFormComponent implements OnInit { if (item) { const vars = this.currentMaterialVariants(); if (vars && vars.length > 0) { - const found = vars.find(v => v.colorName === item.color); + const found = item.filamentVariantId + ? vars.find(v => v.id === item.filamentVariantId) + : vars.find(v => v.colorName === item.color); if (found) return found.hexColor; } return getColorHex(item.color); @@ -240,10 +249,12 @@ export class UploadFormComponent implements OnInit { }); } - updateItemColor(index: number, newColor: string) { + updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) { + const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName; + const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId; this.items.update(current => { const updated = [...current]; - updated[index] = { ...updated[index], color: newColor }; + updated[index] = { ...updated[index], color: colorName, filamentVariantId }; return updated; }); } @@ -261,9 +272,14 @@ export class UploadFormComponent implements OnInit { setFiles(files: File[]) { const validItems: FormItem[] = []; + const defaultSelection = this.getDefaultVariantSelection(); for (const file of files) { - // Default color is Black or derive from somewhere if possible, but here we just init - validItems.push({ file, quantity: 1, color: 'Black' }); + validItems.push({ + file, + quantity: 1, + color: defaultSelection.colorName, + filamentVariantId: defaultSelection.filamentVariantId + }); } if (validItems.length > 0) { @@ -274,6 +290,39 @@ export class UploadFormComponent implements OnInit { } } + private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } { + const vars = this.currentMaterialVariants(); + if (vars && vars.length > 0) { + const preferred = vars.find(v => !v.isOutOfStock) || vars[0]; + return { + colorName: preferred.colorName, + filamentVariantId: preferred.id + }; + } + return { colorName: 'Black' }; + } + + private syncItemVariantSelections(): void { + const vars = this.currentMaterialVariants(); + if (!vars || vars.length === 0) { + return; + } + + const fallback = vars.find(v => !v.isOutOfStock) || vars[0]; + this.items.update(current => current.map(item => { + const byId = item.filamentVariantId != null + ? vars.find(v => v.id === item.filamentVariantId) + : null; + const byColor = vars.find(v => v.colorName === item.color); + const selected = byId || byColor || fallback; + return { + ...item, + color: selected.colorName, + filamentVariantId: selected.id + }; + })); + } + patchSettings(settings: any) { if (!settings) return; // settings object matches keys in our form? 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 bb86e5c..9c65986 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; export interface QuoteRequest { - items: { file: File, quantity: number, color?: string }[]; + items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[]; material: string; quality: string; notes?: string; @@ -26,6 +26,7 @@ export interface QuoteItem { quantity: number; material?: string; color?: string; + filamentVariantId?: number; } export interface QuoteResult { @@ -72,9 +73,13 @@ export interface MaterialOption { variants: VariantOption[]; } export interface VariantOption { + id: number; name: string; colorName: string; hexColor: string; + finishType: string; + stockSpools: number; + stockFilamentGrams: number; isOutOfStock: boolean; } export interface QualityOption { @@ -250,6 +255,7 @@ export class QuoteEstimatorService { const settings = { complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(), material: request.material, + filamentVariantId: item.filamentVariantId, quality: easyPreset ? easyPreset.quality : request.quality, supportsEnabled: request.supportEnabled, color: item.color || '#FFFFFF', @@ -351,7 +357,8 @@ export class QuoteEstimatorService { material: session.materialCode, // Assumption: session has one material for all? or items have it? // Backend model QuoteSession has materialCode. // But line items might have different colors. - color: item.colorCode + color: item.colorCode, + filamentVariantId: item.filamentVariantId })), setupCost: session.setupCostChf || 0, globalMachineCost: sessionData.globalMachineCostChf || 0, diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.html b/frontend/src/app/shared/components/color-selector/color-selector.component.html index d861214..89757fe 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.html +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.html @@ -23,7 +23,7 @@ [class.disabled]="color.outOfStock">
diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.ts b/frontend/src/app/shared/components/color-selector/color-selector.component.ts index 594f9a5..bf6cc55 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.ts +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.ts @@ -13,25 +13,33 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim }) export class ColorSelectorComponent { selectedColor = input('Black'); + selectedVariantId = input(null); variants = input([]); - colorSelected = output(); + colorSelected = output<{ colorName: string; filamentVariantId?: number }>(); isOpen = signal(false); 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: 'COLOR.AVAILABLE_COLORS', - colors: vars.map(v => ({ - label: v.colorName, // Display "Red" - value: v.colorName, // Send "Red" to backend + const byFinish = new Map(); + vars.forEach(v => { + const finish = v.finishType || 'AVAILABLE_COLORS'; + const bucket = byFinish.get(finish) || []; + bucket.push({ + label: v.colorName, + value: v.colorName, hex: v.hexColor, + variantId: v.id, outOfStock: v.isOutOfStock - })) - }] as ColorCategory[]; + }); + byFinish.set(finish, bucket); + }); + + return Array.from(byFinish.entries()).map(([finish, colors]) => ({ + name: finish, + colors + })) as ColorCategory[]; } return PRODUCT_COLORS; }); @@ -42,8 +50,11 @@ export class ColorSelectorComponent { selectColor(color: ColorOption) { if (color.outOfStock) return; - - this.colorSelected.emit(color.value); + + this.colorSelected.emit({ + colorName: color.value, + filamentVariantId: color.variantId + }); this.isOpen.set(false); } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 89a4a4d..8fd59c1 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -133,8 +133,6 @@ "TITLE": "Über uns", "EYEBROW": "3D-Druck-Labor", "SUBTITLE": "Wir sind zwei Studenten mit viel Motivation und Lernbereitschaft.", - "HOW_TEXT": "3D Fab entstand aus Matteos anfänglichem Interesse am 3D-Druck. Er kaufte einen Drucker und begann ernsthaft zu experimentieren.\nIrgendwann kamen die ersten Anfragen: ein gebrochenes Teil zum Ersetzen, ein Ersatzteil, das man nicht findet, ein praktischer Adapter. Die Anfragen nahmen zu und wir sagten uns: okay, machen wir es richtig.\nSpäter haben wir einen Rechner entwickelt, um die Kosten im Voraus zu verstehen: das war einer der ersten Schritte vom „wir machen ein paar Teile“ zu einem echten Projekt – gemeinsam.", - "PASSIONS_TITLE": "Unsere Leidenschaften", "PASSION_BIKE_TRIAL": "Bike Trial", "PASSION_MOUNTAIN": "Berge", "PASSION_SKI": "Ski", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index c9fb65c..2430c61 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -133,8 +133,6 @@ "TITLE": "About Us", "EYEBROW": "3D Printing Lab", "SUBTITLE": "We are two students with a strong desire to build and learn.", - "HOW_TEXT": "3D Fab was born from Matteo's initial interest in 3D printing. He bought a printer and started experimenting seriously. \n At a certain point, the first requests arrived: a broken part to replace, a spare part that cannot be found, a handy adapter to have. The requests increased and we said: okay, let's do it properly.\nLater we created a calculator to understand the cost in advance: it was one of the first steps that took us from \"let's make a few parts\" to a real project, together.", - "PASSIONS_TITLE": "Our passions", "PASSION_BIKE_TRIAL": "Bike trial", "PASSION_MOUNTAIN": "Mountain", "PASSION_SKI": "Ski", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 00dffc8..eb945dd 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -190,8 +190,6 @@ "TITLE": "Qui sommes-nous", "EYEBROW": "Atelier d'impression 3D", "SUBTITLE": "Nous sommes deux étudiants avec beaucoup d'envie de faire et d'apprendre.", - "HOW_TEXT": "3D Fab est né de l'intérêt initial de Matteo pour l'impression 3D. Il a acheté une imprimante et a commencé à expérimenter sérieusement. \n À un certain moment, les premières demandes sont arrivées : une pièce cassée à remplacer, une pièce de rechange introuvable, un adaptateur pratique à avoir. Les demandes ont augmenté et nous nous sommes dit : d'accord, faisons-le bien.\nEnsuite, nous avons créé un calculateur pour connaître le coût à l'avance : cela a été l'un des premiers pas qui nous a fait passer de « on fait quelques pièces » à un vrai projet, ensemble.", - "PASSIONS_TITLE": "Nos passions", "PASSION_BIKE_TRIAL": "Bike trial", "PASSION_MOUNTAIN": "Montagne", "PASSION_SKI": "Ski", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index b17a3b5..15437f2 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -190,8 +190,8 @@ "TITLE": "Chi Siamo", "EYEBROW": "Laboratorio di stampa 3D", "SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.", - "HOW_TEXT": "3D Fab nasce dall’interesse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.", - "PASSIONS_TITLE": "Le nostre passioni", + "HOW_TEXT": "3D Fab nasce per trasformare le potenzialità della stampa 3D in soluzioni quotidiane. Siamo partiti dalla curiosità tecnica e siamo arrivati alla produzione di ricambi, prodotti e prototipi su misura. Per passare da un'idea a un progetto concreto abbiamo lanciato il nostro calcolatore automatico: preventivi chiari in un clic per garantirti un servizio professionale e senza sorprese sul prezzo.", + "PASSIONS_TITLE": "I nostri interessi", "PASSION_BIKE_TRIAL": "Bike trial", "PASSION_MOUNTAIN": "Montagna", "PASSION_SKI": "Ski",