feat(back-end): new stock db and back-office improvements

This commit is contained in:
2026-03-02 20:19:19 +01:00
parent 02e58ea00f
commit b7c399e3cb
39 changed files with 1605 additions and 257 deletions

View File

@@ -3,18 +3,27 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse; import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; 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.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository; import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.OrcaProfileResolver;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList; import java.math.BigDecimal;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -24,89 +33,177 @@ public class OptionsController {
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo; private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo, LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo) { NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo; this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
public ResponseEntity<OptionsResponse> getOptions() { public ResponseEntity<OptionsResponse> getOptions(
// 1. Materials & Variants @RequestParam(value = "printerMachineId", required = false) Long printerMachineId,
@RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter
) {
List<FilamentMaterialType> types = materialRepo.findAll(); List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findAll(); List<FilamentVariant> 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<Long> compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter);
List<OptionsResponse.MaterialOption> materialOptions = types.stream() List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER))
.map(type -> { .map(type -> {
if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) {
return null;
}
List<OptionsResponse.VariantOption> variants = allVariants.stream() List<OptionsResponse.VariantOption> 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( .map(v -> new OptionsResponse.VariantOption(
v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
getColorHex(v.getColorName()), // Need helper or store hex in DB resolveHexColor(v),
v.getStockSpools().doubleValue() <= 0 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()); .collect(Collectors.toList());
// Only include material if it has active variants if (variants.isEmpty()) {
if (variants.isEmpty()) return null; return null;
}
return new OptionsResponse.MaterialOption( return new OptionsResponse.MaterialOption(
type.getMaterialCode(), type.getMaterialCode(),
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"), type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"),
variants variants
); );
}) })
.filter(m -> m != null) .filter(m -> m != null)
.collect(Collectors.toList()); .toList();
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of( List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"), new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"), new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition") new OptionsResponse.QualityOption("extra_fine", "High Definition")
); );
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of( List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"), new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
// 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream() List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> l.getIsActive()) .filter(l -> Boolean.TRUE.equals(l.getIsActive()))
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO( .map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(), l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm()) String.format("%.2f mm", l.getLayerHeightMm())
)) ))
.collect(Collectors.toList()); .toList();
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> n.getIsActive()) .filter(n -> Boolean.TRUE.equals(n.getIsActive()))
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO( .map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(), n.getNozzleDiameterMm().doubleValue(),
String.format("%.1f mm%s", n.getNozzleDiameterMm(), String.format("%.1f mm%s", n.getNozzleDiameterMm(),
n.getExtraNozzleChangeFeeChf().doubleValue() > 0 n.getExtraNozzleChangeFeeChf().doubleValue() > 0
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)") : " (Standard)")
)) ))
.collect(Collectors.toList()); .toList();
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
} }
// Temporary helper until we add hex to DB private Set<Long> 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<MaterialOrcaProfileMap> 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) { private String getColorHex(String colorName) {
if (colorName == null) {
return "#9e9e9e";
}
String lower = colorName.toLowerCase(); String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a"; if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5"; 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("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d"; if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; // Default grey return "#9e9e9e";
} }
} }

View File

@@ -1,14 +1,19 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.ModelDimensions; import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -43,18 +48,20 @@ public class QuoteSessionController {
private final SlicerService slicerService; private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; 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.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService; 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, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService, SlicerService slicerService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo, PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService) { com.printcalculator.service.ClamAVService clamAVService) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
@@ -62,6 +69,9 @@ public class QuoteSessionController {
this.slicerService = slicerService; this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
} }
@@ -129,45 +139,31 @@ public class QuoteSessionController {
// Apply Basic/Advanced Logic // Apply Basic/Advanced Logic
applyPrintSettings(settings); 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 // Update session global settings from the most recent item added
session.setMaterialCode(settings.getMaterial()); session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4)); session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2));
session.setInfillPattern(settings.getInfillPattern()); session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
sessionRepo.save(session); sessionRepo.save(session);
// REAL SLICING OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
// 1. Pick Machine (default to first active or specific) String machineProfile = profiles.machineProfileName();
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() String filamentProfile = profiles.filamentProfileName();
.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";
}
String processProfile = "0.20mm Standard @BBL A1"; String processProfile = "standard";
// 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
if (settings.getLayerHeight() != null) { if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1"; if (settings.getLayerHeight() >= 0.28) processProfile = "draft";
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1"; else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine";
} }
// Build overrides map from settings // Build overrides map from settings
@@ -189,7 +185,7 @@ public class QuoteSessionController {
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile()); Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
// 4. Calculate Quote // 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
// 5. Create Line Item // 5. Create Line Item
QuoteLineItem item = new QuoteLineItem(); QuoteLineItem item = new QuoteLineItem();
@@ -197,7 +193,8 @@ public class QuoteSessionController {
item.setOriginalFilename(file.getOriginalFilename()); item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(persistentPath.toString()); // SAVE PATH item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setQuantity(1); item.setQuantity(1);
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
item.setStatus("READY"); // or CALCULATED item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds()); 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<FilamentVariant> 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 // 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}") @PatchMapping("/line-items/{lineItemId}")
@Transactional @Transactional
@@ -344,6 +385,7 @@ public class QuoteSessionController {
dto.put("printTimeSeconds", item.getPrintTimeSeconds()); dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams()); dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode()); dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("status", item.getStatus()); dto.put("status", item.getStatus());
BigDecimal unitPrice = item.getUnitPriceChf(); BigDecimal unitPrice = item.getUnitPriceChf();

View File

@@ -8,6 +8,8 @@ import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -17,8 +19,12 @@ import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; 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.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController @RestController
@@ -26,16 +32,26 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminFilamentController { public class AdminFilamentController {
private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); 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<String> ALLOWED_FINISH_TYPES = Set.of(
"GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL"
);
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentController( public AdminFilamentController(
FilamentMaterialTypeRepository materialRepo, FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo
) { ) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
} }
@GetMapping("/materials") @GetMapping("/materials")
@@ -130,6 +146,20 @@ public class AdminFilamentController {
return ResponseEntity.ok(toVariantDto(saved)); return ResponseEntity.ok(toVariantDto(saved));
} }
@DeleteMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<Void> 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( private void applyMaterialPayload(
FilamentMaterialType material, FilamentMaterialType material,
AdminUpsertFilamentMaterialTypeRequest payload, AdminUpsertFilamentMaterialTypeRequest payload,
@@ -156,10 +186,17 @@ public class AdminFilamentController {
String normalizedDisplayName, String normalizedDisplayName,
String normalizedColorName String normalizedColorName
) { ) {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand());
variant.setFilamentMaterialType(material); variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName); variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName); 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.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial()));
variant.setCostChfPerKg(payload.getCostChfPerKg()); variant.setCostChfPerKg(payload.getCostChfPerKg());
variant.setStockSpools(payload.getStockSpools()); variant.setStockSpools(payload.getStockSpools());
@@ -188,6 +225,35 @@ public class AdminFilamentController {
return value.trim(); 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) { private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) { if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
@@ -268,16 +334,20 @@ public class AdminFilamentController {
dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName()); dto.setColorName(variant.getColorName());
dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand());
dto.setIsMatte(variant.getIsMatte()); dto.setIsMatte(variant.getIsMatte());
dto.setIsSpecial(variant.getIsSpecial()); dto.setIsSpecial(variant.getIsSpecial());
dto.setCostChfPerKg(variant.getCostChfPerKg()); dto.setCostChfPerKg(variant.getCostChfPerKg());
dto.setStockSpools(variant.getStockSpools()); dto.setStockSpools(variant.getStockSpools());
dto.setSpoolNetKg(variant.getSpoolNetKg()); dto.setSpoolNetKg(variant.getSpoolNetKg());
BigDecimal stockKg = BigDecimal.ZERO;
if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) {
dto.setStockKg(variant.getStockSpools().multiply(variant.getSpoolNetKg())); stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg());
} else {
dto.setStockKg(BigDecimal.ZERO);
} }
dto.setStockKg(stockKg);
dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000)));
dto.setIsActive(variant.getIsActive()); dto.setIsActive(variant.getIsActive());
dto.setCreatedAt(variant.getCreatedAt()); dto.setCreatedAt(variant.getCreatedAt());
return dto; return dto;

View File

@@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
@@ -105,6 +106,10 @@ public class AdminOperationsController {
dto.setStockSpools(stock.getStockSpools()); dto.setStockSpools(stock.getStockSpools());
dto.setSpoolNetKg(stock.getSpoolNetKg()); dto.setSpoolNetKg(stock.getSpoolNetKg());
dto.setStockKg(stock.getStockKg()); dto.setStockKg(stock.getStockKg());
BigDecimal grams = stock.getStockKg() != null
? stock.getStockKg().multiply(BigDecimal.valueOf(1000))
: BigDecimal.ZERO;
dto.setStockFilamentGrams(grams);
if (variant != null) { if (variant != null) {
dto.setMaterialCode( dto.setMaterialCode(

View File

@@ -10,6 +10,7 @@ public class AdminFilamentStockDto {
private BigDecimal stockSpools; private BigDecimal stockSpools;
private BigDecimal spoolNetKg; private BigDecimal spoolNetKg;
private BigDecimal stockKg; private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean active; private Boolean active;
public Long getFilamentVariantId() { public Long getFilamentVariantId() {
@@ -68,6 +69,14 @@ public class AdminFilamentStockDto {
this.stockKg = stockKg; this.stockKg = stockKg;
} }
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getActive() { public Boolean getActive() {
return active; return active;
} }

View File

@@ -12,12 +12,16 @@ public class AdminFilamentVariantDto {
private String materialTechnicalTypeLabel; private String materialTechnicalTypeLabel;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte; private Boolean isMatte;
private Boolean isSpecial; private Boolean isSpecial;
private BigDecimal costChfPerKg; private BigDecimal costChfPerKg;
private BigDecimal stockSpools; private BigDecimal stockSpools;
private BigDecimal spoolNetKg; private BigDecimal spoolNetKg;
private BigDecimal stockKg; private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean isActive; private Boolean isActive;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@@ -85,6 +89,30 @@ public class AdminFilamentVariantDto {
this.colorName = colorName; 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() { public Boolean getIsMatte() {
return isMatte; return isMatte;
} }
@@ -133,6 +161,14 @@ public class AdminFilamentVariantDto {
this.stockKg = stockKg; this.stockKg = stockKg;
} }
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getIsActive() { public Boolean getIsActive() {
return isActive; return isActive;
} }

View File

@@ -6,6 +6,9 @@ public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId; private Long materialTypeId;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte; private Boolean isMatte;
private Boolean isSpecial; private Boolean isSpecial;
private BigDecimal costChfPerKg; private BigDecimal costChfPerKg;
@@ -37,6 +40,30 @@ public class AdminUpsertFilamentVariantRequest {
this.colorName = colorName; 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() { public Boolean getIsMatte() {
return isMatte; return isMatte;
} }

View File

@@ -10,7 +10,16 @@ public record OptionsResponse(
List<NozzleOptionDTO> nozzleDiameters List<NozzleOptionDTO> nozzleDiameters
) { ) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {} public record MaterialOption(String code, String label, List<VariantOption> 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 QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {} public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {} public record LayerHeightOptionDTO(double value, String label) {}

View File

@@ -10,6 +10,8 @@ public class PrintSettingsDto {
// Common // Common
private String material; // e.g. "PLA", "PETG" private String material; // e.g. "PLA", "PETG"
private String color; // e.g. "White", "#FFFFFF" private String color; // e.g. "White", "#FFFFFF"
private Long filamentVariantId;
private Long printerMachineId;
// Basic Mode // Basic Mode
private String quality; // "draft", "standard", "high" private String quality; // "draft", "standard", "high"

View File

@@ -24,6 +24,16 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; 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") @ColumnDefault("false")
@Column(name = "is_matte", nullable = false) @Column(name = "is_matte", nullable = false)
private Boolean isMatte; private Boolean isMatte;
@@ -83,6 +93,30 @@ public class FilamentVariant {
this.colorName = colorName; 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() { public Boolean getIsMatte() {
return isMatte; return isMatte;
} }
@@ -139,4 +173,4 @@ public class FilamentVariant {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
} }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -44,6 +44,10 @@ public class OrderItem {
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode; private String materialCode;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant;
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@@ -158,6 +162,14 @@ public class OrderItem {
this.materialCode = materialCode; this.materialCode = materialCode;
} }
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }

View File

@@ -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;
}
}

View File

@@ -40,6 +40,11 @@ public class QuoteLineItem {
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; 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) @Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm; private BigDecimal boundingBoxXMm;
@@ -124,6 +129,14 @@ public class QuoteLineItem {
this.colorCode = colorCode; this.colorCode = colorCode;
} }
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public BigDecimal getBoundingBoxXMm() { public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm; return boundingBoxXMm;
} }
@@ -212,4 +225,4 @@ public class QuoteLineItem {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
} }

View File

@@ -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<FilamentVariantOrcaOverride, Long> {
Optional<FilamentVariantOrcaOverride> findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(
FilamentVariant filamentVariant,
PrinterMachineProfile printerMachineProfile
);
}

View File

@@ -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<MaterialOrcaProfileMap, Long> {
Optional<MaterialOrcaProfileMap> findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
PrinterMachineProfile printerMachineProfile,
FilamentMaterialType filamentMaterialType
);
List<MaterialOrcaProfileMap> findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile);
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> { public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId); List<OrderItem> findByOrder_Id(UUID orderId);
} boolean existsByFilamentVariant_Id(Long filamentVariantId);
}

View File

@@ -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<PrinterMachineProfile, Long> {
Optional<PrinterMachineProfile> findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm);
Optional<PrinterMachineProfile> findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine);
List<PrinterMachineProfile> findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine);
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
} boolean existsByFilamentVariant_Id(Long filamentVariantId);
}

View File

@@ -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<PrinterMachineProfile> 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<PrinterMachineProfile> resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null) {
return Optional.empty();
}
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle != null) {
Optional<PrinterMachineProfile> exact = machineProfileRepo
.findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle);
if (exact.isPresent()) {
return exact;
}
}
Optional<PrinterMachineProfile> defaultProfile = machineProfileRepo
.findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine);
if (defaultProfile.isPresent()) {
return defaultProfile;
}
return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine)
.stream()
.findFirst();
}
private Optional<String> resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) {
if (machineProfile == null || variant == null) {
return Optional.empty();
}
Optional<FilamentVariantOrcaOverride> override = variantOverrideRepo
.findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile);
if (override.isPresent()) {
return Optional.ofNullable(override.get().getOrcaFilamentProfileName());
}
Optional<MaterialOrcaProfileMap> 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
) {}
}

View File

@@ -203,7 +203,14 @@ public class OrderService {
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity()); oItem.setQuantity(qItem.getQuantity());
oItem.setColorCode(qItem.getColorCode()); 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(); BigDecimal distributedUnitPrice = qItem.getUnitPriceChf();
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {

View File

@@ -60,40 +60,49 @@ public class QuoteCalculator {
.orElseThrow(() -> new RuntimeException("No active printer found")); .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); String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode) FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + 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) FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode)); .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 PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); 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()); 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())
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
// Energy Cost: (watts / 1000) * hours * costPerKwh BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts())
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(totalHours); BigDecimal kwh = kw.multiply(totalHours);
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh()); BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
// Subtotal (Costs without Fixed Fees and without Machine Tiers)
BigDecimal subtotal = materialCost.add(energyCost); BigDecimal subtotal = materialCost.add(energyCost);
BigDecimal markupFactor = BigDecimal.ONE.add(
// Markup policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
// Markup is percentage (e.g. 20.0) );
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
subtotal = subtotal.multiply(markupFactor); subtotal = subtotal.multiply(markupFactor);
return new QuoteResult(subtotal.doubleValue(), "CHF", stats); return new QuoteResult(subtotal.doubleValue(), "CHF", stats);
} }
public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) { public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) {

204
db.sql
View File

@@ -44,6 +44,10 @@ create table filament_variant
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc. 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_matte boolean not null default false,
is_special 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 (stock_spools * spool_net_kg) as stock_kg
from filament_variant; 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 create table pricing_policy
@@ -252,6 +287,7 @@ insert into filament_material_type (material_code,
values ('PLA', false, false, null), values ('PLA', false, false, null),
('PETG', false, false, null), ('PETG', false, false, null),
('TPU', true, false, null), ('TPU', true, false, null),
('PC', false, true, 'engineering'),
('ABS', false, false, null), ('ABS', false, false, null),
('Nylon', false, false, null), ('Nylon', false, false, null),
('Carbon PLA', false, false, null) ('Carbon PLA', false, false, null)
@@ -275,6 +311,9 @@ insert
into filament_variant (filament_material_type_id, into filament_variant (filament_material_type_id,
variant_display_name, variant_display_name,
color_name, color_name,
color_hex,
finish_type,
brand,
is_matte, is_matte,
is_special, is_special,
cost_chf_per_kg, cost_chf_per_kg,
@@ -284,6 +323,9 @@ into filament_variant (filament_material_type_id,
select pla.filament_material_type_id, select pla.filament_material_type_id,
v.variant_display_name, v.variant_display_name,
v.color_name, v.color_name,
v.color_hex,
v.finish_type,
null::text as brand,
v.is_matte, v.is_matte,
v.is_special, v.is_special,
18.00, -- PLA da Excel 18.00, -- PLA da Excel
@@ -291,17 +333,114 @@ select pla.filament_material_type_id,
1.000, 1.000,
true true
from pla from pla
cross join (values ('PLA Bianco', 'Bianco', false, false, 3.000::numeric), cross join (values ('PLA Bianco', 'Bianco', '#F5F5F5', 'GLOSSY', false, false, 3.000::numeric),
('PLA Nero', 'Nero', false, false, 3.000::numeric), ('PLA Nero', 'Nero', '#1A1A1A', 'GLOSSY', false, false, 3.000::numeric),
('PLA Blu', 'Blu', false, false, 1.000::numeric), ('PLA Blu', 'Blu', '#1976D2', 'GLOSSY', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', false, false, 1.000::numeric), ('PLA Arancione', 'Arancione', '#FFA726', 'GLOSSY', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', false, false, 1.000::numeric), ('PLA Grigio', 'Grigio', '#BDBDBD', 'GLOSSY', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric), ('PLA Grigio Scuro', 'Grigio scuro', '#424242', 'MATTE', true, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric), ('PLA Grigio Chiaro', 'Grigio chiaro', '#D6D6D6', 'MATTE', true, false, 1.000::numeric),
('PLA Viola', 'Viola', false, false, ('PLA Viola', 'Viola', '#7B1FA2', 'GLOSSY', false, false,
1.000::numeric)) as v(variant_display_name, color_name, is_matte, is_special, stock_spools) 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 on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name, 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_matte = excluded.is_matte,
is_special = excluded.is_special, is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg, 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, extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
is_active = excluded.is_active; 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) -- 6) Layer heights (opzioni)
@@ -420,6 +604,7 @@ CREATE TABLE IF NOT EXISTS quote_line_items
original_filename text NOT NULL, original_filename text NOT NULL,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
color_code text, -- es: white/black o codice interno color_code text, -- es: white/black o codice interno
filament_variant_id bigint REFERENCES filament_variant (filament_variant_id),
-- Output slicing / calcolo -- Output slicing / calcolo
bounding_box_x_mm numeric(10, 3), 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 sha256_hex text, -- opzionale, utile anche per dedup interno
material_code text NOT NULL, material_code text NOT NULL,
filament_variant_id bigint REFERENCES filament_variant (filament_variant_id),
color_code text, color_code text,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),

View File

@@ -2,6 +2,7 @@ export interface ColorOption {
label: string; label: string;
value: string; value: string;
hex: string; hex: string;
variantId?: number;
outOfStock?: boolean; outOfStock?: boolean;
} }

View File

@@ -14,48 +14,171 @@
<div class="content" *ngIf="!loading; else loadingTpl"> <div class="content" *ngIf="!loading; else loadingTpl">
<section class="panel"> <section class="panel">
<h3>Inserimento rapido</h3> <div class="panel-header">
<div class="create-grid"> <h3>Inserimento rapido</h3>
<section class="subpanel"> <button type="button" class="panel-toggle" (click)="toggleQuickInsertCollapsed()">
<h4>Nuovo materiale</h4> {{ quickInsertCollapsed ? 'Espandi' : 'Collassa' }}
<div class="form-grid"> </button>
<label class="form-field form-field--wide"> </div>
<span>Codice materiale</span>
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." /> <div *ngIf="!quickInsertCollapsed; else quickInsertCollapsedTpl">
</label> <div class="create-grid">
<label class="form-field form-field--wide"> <section class="subpanel">
<span>Etichetta tecnico</span> <h4>Nuovo materiale</h4>
<input <div class="form-grid">
type="text" <label class="form-field form-field--wide">
[(ngModel)]="newMaterial.technicalTypeLabel" <span>Codice materiale</span>
[disabled]="!newMaterial.isTechnical" <input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
placeholder="alta temperatura, rinforzato..." </label>
/> <label class="form-field form-field--wide">
</label> <span>Etichetta tecnico</span>
<input
type="text"
[(ngModel)]="newMaterial.technicalTypeLabel"
[disabled]="!newMaterial.isTechnical"
placeholder="alta temperatura, rinforzato..."
/>
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
<span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
</button>
</section>
<section class="subpanel">
<h4>Nuova variante</h4>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
</label>
<label class="form-field">
<span>Hex colore</span>
<input type="text" [(ngModel)]="newVariant.colorHex" placeholder="#1A1A1A" />
</label>
<label class="form-field">
<span>Finitura</span>
<select [(ngModel)]="newVariant.finishType">
<option value="GLOSSY">GLOSSY</option>
<option value="MATTE">MATTE</option>
<option value="MARBLE">MARBLE</option>
<option value="SILK">SILK</option>
<option value="TRANSLUCENT">TRANSLUCENT</option>
<option value="SPECIAL">SPECIAL</option>
</select>
</label>
<label class="form-field">
<span>Brand</span>
<input type="text" [(ngModel)]="newVariant.brand" placeholder="Bambu, SUNLU..." />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Stock spools: <strong>{{ newVariant.stockSpools | number:'1.0-3' }}</strong> |
Filamento totale: <strong>{{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g</strong>
</p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
</button>
</section>
</div>
</div>
</section>
<section class="panel">
<h3>Varianti filamento</h3>
<div class="variant-list">
<article class="variant-row" *ngFor="let variant of variants; trackBy: trackById">
<div class="variant-header">
<button
type="button"
class="expand-toggle"
(click)="toggleVariantExpanded(variant.id)"
[attr.aria-expanded]="isVariantExpanded(variant.id)">
{{ isVariantExpanded(variant.id) ? '▾' : '▸' }}
</button>
<div class="variant-head-main">
<strong>{{ variant.variantDisplayName }}</strong>
<div class="variant-collapsed-summary" *ngIf="!isVariantExpanded(variant.id)">
<span class="color-summary">
<span class="color-dot" [style.background-color]="getVariantColorHex(variant)"></span>
{{ variant.colorName || 'N/D' }}
</span>
<span>Stock spools: {{ variant.stockSpools | number:'1.0-3' }}</span>
<span>Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</span>
</div>
</div>
<div class="variant-head-actions">
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
<button
type="button"
class="btn-delete"
(click)="openDeleteVariant(variant)"
[disabled]="deletingVariantIds.has(variant.id)">
{{ deletingVariantIds.has(variant.id) ? 'Eliminazione...' : 'Elimina' }}
</button>
</div>
</div> </div>
<div class="toggle-group"> <div class="form-grid" *ngIf="isVariantExpanded(variant.id)">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
<span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
</button>
</section>
<section class="subpanel">
<h4>Nuova variante</h4>
<div class="form-grid">
<label class="form-field"> <label class="form-field">
<span>Materiale</span> <span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId"> <select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id"> <option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }} {{ material.materialCode }}
</option> </option>
@@ -63,50 +186,75 @@
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Nome variante</span> <span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" /> <input type="text" [(ngModel)]="variant.variantDisplayName" />
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Colore</span> <span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." /> <input type="text" [(ngModel)]="variant.colorName" />
</label>
<label class="form-field">
<span>Hex colore</span>
<input type="text" [(ngModel)]="variant.colorHex" />
</label>
<label class="form-field">
<span>Finitura</span>
<select [(ngModel)]="variant.finishType">
<option value="GLOSSY">GLOSSY</option>
<option value="MATTE">MATTE</option>
<option value="MARBLE">MARBLE</option>
<option value="SILK">SILK</option>
<option value="TRANSLUCENT">TRANSLUCENT</option>
<option value="SPECIAL">SPECIAL</option>
</select>
</label>
<label class="form-field">
<span>Brand</span>
<input type="text" [(ngModel)]="variant.brand" />
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Costo CHF/kg</span> <span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" /> <input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Stock spool</span> <span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" /> <input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Spool netto kg</span> <span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" /> <input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
</label> </label>
</div> </div>
<div class="toggle-group"> <div class="toggle-group" *ngIf="isVariantExpanded(variant.id)">
<label class="toggle"> <label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isMatte" /> <input type="checkbox" [(ngModel)]="variant.isMatte" />
<span>Matte</span> <span>Matte</span>
</label> </label>
<label class="toggle"> <label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" /> <input type="checkbox" [(ngModel)]="variant.isSpecial" />
<span>Special</span> <span>Special</span>
</label> </label>
<label class="toggle"> <label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isActive" /> <input type="checkbox" [(ngModel)]="variant.isActive" />
<span>Attiva</span> <span>Attiva</span>
</label> </label>
</div> </div>
<p class="variant-meta"> <p class="variant-meta" *ngIf="isVariantExpanded(variant.id)">
Stock stimato: <strong>{{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg</strong> Stock spools: <strong>{{ variant.stockSpools | number:'1.0-3' }}</strong> |
Filamento totale: <strong>{{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</strong>
</p> </p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length"> <button
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }} type="button"
*ngIf="isVariantExpanded(variant.id)"
(click)="saveVariant(variant)"
[disabled]="savingVariantIds.has(variant.id)">
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
</button> </button>
</section> </article>
</div> </div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
</section> </section>
<section class="panel"> <section class="panel">
@@ -150,74 +298,6 @@
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p> <p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
</div> </div>
</section> </section>
<section class="panel">
<h3>Varianti filamento</h3>
<div class="variant-grid">
<article class="variant-card" *ngFor="let variant of variants; trackBy: trackById">
<div class="variant-header">
<strong>{{ variant.variantDisplayName }}</strong>
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
</div>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="variant.variantDisplayName" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="variant.colorName" />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Totale stimato: <strong>{{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg</strong>
</p>
<button type="button" (click)="saveVariant(variant)" [disabled]="savingVariantIds.has(variant.id)">
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
</button>
</article>
</div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
</section>
</div> </div>
</section> </section>
@@ -228,3 +308,24 @@
<ng-template #materialsCollapsedTpl> <ng-template #materialsCollapsedTpl>
<p class="muted">Sezione collassata ({{ materials.length }} materiali).</p> <p class="muted">Sezione collassata ({{ materials.length }} materiali).</p>
</ng-template> </ng-template>
<ng-template #quickInsertCollapsedTpl>
<p class="muted">Sezione collassata.</p>
</ng-template>
<div class="dialog-backdrop" *ngIf="variantToDelete" (click)="closeDeleteVariantDialog()"></div>
<div class="confirm-dialog" *ngIf="variantToDelete">
<h4>Sei sicuro?</h4>
<p>Vuoi eliminare la variante <strong>{{ variantToDelete?.variantDisplayName }}</strong>?</p>
<p class="muted">L'operazione non è reversibile.</p>
<div class="dialog-actions">
<button type="button" class="btn-secondary" (click)="closeDeleteVariantDialog()">Annulla</button>
<button
type="button"
class="btn-delete"
(click)="confirmDeleteVariant()"
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)">
{{ variantToDelete && deletingVariantIds.has(variantToDelete.id) ? 'Eliminazione...' : 'Conferma elimina' }}
</button>
</div>
</div>

View File

@@ -141,24 +141,30 @@ select:disabled {
} }
.material-grid, .material-grid,
.variant-grid { .variant-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-3); gap: var(--space-3);
} }
.material-card, .material-card,
.variant-card { .variant-row {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-neutral-100); background: var(--color-neutral-100);
padding: var(--space-3); padding: var(--space-3);
} }
.material-grid {
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.variant-list {
grid-template-columns: 1fr;
}
.variant-header { .variant-header {
display: flex; display: flex;
justify-content: space-between; align-items: flex-start;
align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
@@ -167,6 +173,57 @@ select:disabled {
font-size: 1rem; 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 { .variant-meta {
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
font-size: 0.9rem; font-size: 0.9rem;
@@ -203,6 +260,25 @@ button:disabled {
background: var(--color-neutral-100); 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 { .badge {
display: inline-block; display: inline-block;
border-radius: 999px; border-radius: 999px;
@@ -236,6 +312,43 @@ button:disabled {
color: var(--color-text-muted); 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) { @media (max-width: 1080px) {
.create-grid { .create-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -9,6 +9,7 @@ import {
AdminUpsertFilamentVariantPayload AdminUpsertFilamentVariantPayload
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { getColorHex } from '../../../core/constants/colors.const';
@Component({ @Component({
selector: 'app-admin-filament-stock', selector: 'app-admin-filament-stock',
@@ -23,11 +24,15 @@ export class AdminFilamentStockComponent implements OnInit {
materials: AdminFilamentMaterialType[] = []; materials: AdminFilamentMaterialType[] = [];
variants: AdminFilamentVariant[] = []; variants: AdminFilamentVariant[] = [];
loading = false; loading = false;
quickInsertCollapsed = false;
materialsCollapsed = true; materialsCollapsed = true;
creatingMaterial = false; creatingMaterial = false;
creatingVariant = false; creatingVariant = false;
savingMaterialIds = new Set<number>(); savingMaterialIds = new Set<number>();
savingVariantIds = new Set<number>(); savingVariantIds = new Set<number>();
deletingVariantIds = new Set<number>();
expandedVariantIds = new Set<number>();
variantToDelete: AdminFilamentVariant | null = null;
errorMessage: string | null = null; errorMessage: string | null = null;
successMessage: string | null = null; successMessage: string | null = null;
@@ -42,6 +47,9 @@ export class AdminFilamentStockComponent implements OnInit {
materialTypeId: 0, materialTypeId: 0,
variantDisplayName: '', variantDisplayName: '',
colorName: '', colorName: '',
colorHex: '',
finishType: 'GLOSSY',
brand: '',
isMatte: false, isMatte: false,
isSpecial: false, isSpecial: false,
costChfPerKg: 0, costChfPerKg: 0,
@@ -66,6 +74,12 @@ export class AdminFilamentStockComponent implements OnInit {
next: ({ materials, variants }) => { next: ({ materials, variants }) => {
this.materials = this.sortMaterials(materials); this.materials = this.sortMaterials(materials);
this.variants = this.sortVariants(variants); 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) { if (!this.newVariant.materialTypeId && this.materials.length > 0) {
this.newVariant.materialTypeId = this.materials[0].id; 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, materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '', variantDisplayName: '',
colorName: '', colorName: '',
colorHex: '',
finishType: 'GLOSSY',
brand: '',
isMatte: false, isMatte: false,
isSpecial: false, isSpecial: false,
costChfPerKg: 0, costChfPerKg: 0,
@@ -221,7 +238,7 @@ export class AdminFilamentStockComponent implements OnInit {
} }
isLowStock(variant: AdminFilamentVariant): boolean { 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 { computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
@@ -234,19 +251,82 @@ export class AdminFilamentStockComponent implements OnInit {
return spools * netKg; return spools * netKg;
} }
computeStockFilamentGrams(stockSpools?: number, spoolNetKg?: number): number {
return this.computeStockKg(stockSpools, spoolNetKg) * 1000;
}
trackById(index: number, item: { id: number }): number { trackById(index: number, item: { id: number }): number {
return item.id; 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 { toggleMaterialsCollapsed(): void {
this.materialsCollapsed = !this.materialsCollapsed; this.materialsCollapsed = !this.materialsCollapsed;
} }
toggleQuickInsertCollapsed(): void {
this.quickInsertCollapsed = !this.quickInsertCollapsed;
}
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload { private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
return { return {
materialTypeId: Number(source.materialTypeId), materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(), variantDisplayName: (source.variantDisplayName || '').trim(),
colorName: (source.colorName || '').trim(), colorName: (source.colorName || '').trim(),
colorHex: (source.colorHex || '').trim() || undefined,
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
brand: (source.brand || '').trim() || undefined,
isMatte: !!source.isMatte, isMatte: !!source.isMatte,
isSpecial: !!source.isSpecial, isSpecial: !!source.isSpecial,
costChfPerKg: Number(source.costChfPerKg ?? 0), costChfPerKg: Number(source.costChfPerKg ?? 0),

View File

@@ -11,6 +11,7 @@ export interface AdminFilamentStockRow {
stockSpools: number; stockSpools: number;
spoolNetKg: number; spoolNetKg: number;
stockKg: number; stockKg: number;
stockFilamentGrams: number;
active: boolean; active: boolean;
} }
@@ -31,12 +32,16 @@ export interface AdminFilamentVariant {
materialTechnicalTypeLabel?: string; materialTechnicalTypeLabel?: string;
variantDisplayName: string; variantDisplayName: string;
colorName: string; colorName: string;
colorHex?: string;
finishType?: string;
brand?: string;
isMatte: boolean; isMatte: boolean;
isSpecial: boolean; isSpecial: boolean;
costChfPerKg: number; costChfPerKg: number;
stockSpools: number; stockSpools: number;
spoolNetKg: number; spoolNetKg: number;
stockKg: number; stockKg: number;
stockFilamentGrams: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
} }
@@ -52,6 +57,9 @@ export interface AdminUpsertFilamentVariantPayload {
materialTypeId: number; materialTypeId: number;
variantDisplayName: string; variantDisplayName: string;
colorName: string; colorName: string;
colorHex?: string;
finishType?: string;
brand?: string;
isMatte: boolean; isMatte: boolean;
isSpecial: boolean; isSpecial: boolean;
costChfPerKg: number; costChfPerKg: number;
@@ -167,6 +175,10 @@ export class AdminOperationsService {
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true }); return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
} }
deleteFilamentVariant(variantId: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true });
}
getContactRequests(): Observable<AdminContactRequest[]> { getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true }); return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
} }

View File

@@ -135,7 +135,10 @@ export class CalculatorPageComponent implements OnInit {
// Assuming index matches. // Assuming index matches.
// Need to be careful if items order changed, but usually ID sort or insert order. // Need to be careful if items order changed, but usually ID sort or insert order.
if (item.colorCode) { if (item.colorCode) {
this.uploadForm.updateItemColor(index, item.colorCode); this.uploadForm.updateItemColor(index, {
colorName: item.colorCode,
filamentVariantId: item.filamentVariantId
});
} }
}); });
} }

View File

@@ -54,6 +54,7 @@
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label> <label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
<app-color-selector <app-color-selector
[selectedColor]="item.color" [selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()" [variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)"> (colorSelected)="updateItemColor(i, $event)">
</app-color-selector> </app-color-selector>

View File

@@ -15,6 +15,7 @@ interface FormItem {
file: File; file: File;
quantity: number; quantity: number;
color: string; color: string;
filamentVariantId?: number;
} }
@Component({ @Component({
@@ -58,6 +59,7 @@ export class UploadFormComponent implements OnInit {
if (matCode && this.fullMaterialOptions.length > 0) { if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode); const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []); this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
} else { } else {
this.currentMaterialVariants.set([]); this.currentMaterialVariants.set([]);
} }
@@ -166,8 +168,13 @@ export class UploadFormComponent implements OnInit {
if (file.size > MAX_SIZE) { if (file.size > MAX_SIZE) {
hasError = true; hasError = true;
} else { } else {
// Default color is Black const defaultSelection = this.getDefaultVariantSelection();
validItems.push({ file, quantity: 1, color: 'Black' }); validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
} }
} }
@@ -220,7 +227,9 @@ export class UploadFormComponent implements OnInit {
if (item) { if (item) {
const vars = this.currentMaterialVariants(); const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) { 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; if (found) return found.hexColor;
} }
return getColorHex(item.color); 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 => { this.items.update(current => {
const updated = [...current]; const updated = [...current];
updated[index] = { ...updated[index], color: newColor }; updated[index] = { ...updated[index], color: colorName, filamentVariantId };
return updated; return updated;
}); });
} }
@@ -261,9 +272,14 @@ export class UploadFormComponent implements OnInit {
setFiles(files: File[]) { setFiles(files: File[]) {
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection();
for (const file of files) { for (const file of files) {
// Default color is Black or derive from somewhere if possible, but here we just init validItems.push({
validItems.push({ file, quantity: 1, color: 'Black' }); file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
} }
if (validItems.length > 0) { 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) { patchSettings(settings: any) {
if (!settings) return; if (!settings) return;
// settings object matches keys in our form? // settings object matches keys in our form?

View File

@@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
export interface QuoteRequest { export interface QuoteRequest {
items: { file: File, quantity: number, color?: string }[]; items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[];
material: string; material: string;
quality: string; quality: string;
notes?: string; notes?: string;
@@ -26,6 +26,7 @@ export interface QuoteItem {
quantity: number; quantity: number;
material?: string; material?: string;
color?: string; color?: string;
filamentVariantId?: number;
} }
export interface QuoteResult { export interface QuoteResult {
@@ -72,9 +73,13 @@ export interface MaterialOption {
variants: VariantOption[]; variants: VariantOption[];
} }
export interface VariantOption { export interface VariantOption {
id: number;
name: string; name: string;
colorName: string; colorName: string;
hexColor: string; hexColor: string;
finishType: string;
stockSpools: number;
stockFilamentGrams: number;
isOutOfStock: boolean; isOutOfStock: boolean;
} }
export interface QualityOption { export interface QualityOption {
@@ -250,6 +255,7 @@ export class QuoteEstimatorService {
const settings = { const settings = {
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(), complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
material: request.material, material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality, quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled, supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF', 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? material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode. // Backend model QuoteSession has materialCode.
// But line items might have different colors. // But line items might have different colors.
color: item.colorCode color: item.colorCode,
filamentVariantId: item.filamentVariantId
})), })),
setupCost: session.setupCostChf || 0, setupCost: session.setupCostChf || 0,
globalMachineCost: sessionData.globalMachineCostChf || 0, globalMachineCost: sessionData.globalMachineCostChf || 0,

View File

@@ -23,7 +23,7 @@
[class.disabled]="color.outOfStock"> [class.disabled]="color.outOfStock">
<div class="selection-ring" <div class="selection-ring"
[class.active]="selectedColor() === color.value" [class.active]="selectedVariantId() ? selectedVariantId() === color.variantId : selectedColor() === color.value"
[class.out-of-stock]="color.outOfStock"> [class.out-of-stock]="color.outOfStock">
<div class="color-circle small" [style.background-color]="color.hex"></div> <div class="color-circle small" [style.background-color]="color.hex"></div>
</div> </div>

View File

@@ -13,25 +13,33 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
}) })
export class ColorSelectorComponent { export class ColorSelectorComponent {
selectedColor = input<string>('Black'); selectedColor = input<string>('Black');
selectedVariantId = input<number | null>(null);
variants = input<VariantOption[]>([]); variants = input<VariantOption[]>([]);
colorSelected = output<string>(); colorSelected = output<{ colorName: string; filamentVariantId?: number }>();
isOpen = signal(false); isOpen = signal(false);
categories = computed(() => { categories = computed(() => {
const vars = this.variants(); const vars = this.variants();
if (vars && vars.length > 0) { if (vars && vars.length > 0) {
// Flatten variants into a single category for now const byFinish = new Map<string, ColorOption[]>();
// We could try to group by extracting words, but "Colors" is fine. vars.forEach(v => {
return [{ const finish = v.finishType || 'AVAILABLE_COLORS';
name: 'COLOR.AVAILABLE_COLORS', const bucket = byFinish.get(finish) || [];
colors: vars.map(v => ({ bucket.push({
label: v.colorName, // Display "Red" label: v.colorName,
value: v.colorName, // Send "Red" to backend value: v.colorName,
hex: v.hexColor, hex: v.hexColor,
variantId: v.id,
outOfStock: v.isOutOfStock 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; return PRODUCT_COLORS;
}); });
@@ -42,8 +50,11 @@ export class ColorSelectorComponent {
selectColor(color: ColorOption) { selectColor(color: ColorOption) {
if (color.outOfStock) return; if (color.outOfStock) return;
this.colorSelected.emit(color.value); this.colorSelected.emit({
colorName: color.value,
filamentVariantId: color.variantId
});
this.isOpen.set(false); this.isOpen.set(false);
} }

View File

@@ -133,8 +133,6 @@
"TITLE": "Über uns", "TITLE": "Über uns",
"EYEBROW": "3D-Druck-Labor", "EYEBROW": "3D-Druck-Labor",
"SUBTITLE": "Wir sind zwei Studenten mit viel Motivation und Lernbereitschaft.", "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_BIKE_TRIAL": "Bike Trial",
"PASSION_MOUNTAIN": "Berge", "PASSION_MOUNTAIN": "Berge",
"PASSION_SKI": "Ski", "PASSION_SKI": "Ski",

View File

@@ -133,8 +133,6 @@
"TITLE": "About Us", "TITLE": "About Us",
"EYEBROW": "3D Printing Lab", "EYEBROW": "3D Printing Lab",
"SUBTITLE": "We are two students with a strong desire to build and learn.", "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_BIKE_TRIAL": "Bike trial",
"PASSION_MOUNTAIN": "Mountain", "PASSION_MOUNTAIN": "Mountain",
"PASSION_SKI": "Ski", "PASSION_SKI": "Ski",

View File

@@ -190,8 +190,6 @@
"TITLE": "Qui sommes-nous", "TITLE": "Qui sommes-nous",
"EYEBROW": "Atelier d'impression 3D", "EYEBROW": "Atelier d'impression 3D",
"SUBTITLE": "Nous sommes deux étudiants avec beaucoup d'envie de faire et d'apprendre.", "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_BIKE_TRIAL": "Bike trial",
"PASSION_MOUNTAIN": "Montagne", "PASSION_MOUNTAIN": "Montagne",
"PASSION_SKI": "Ski", "PASSION_SKI": "Ski",

View File

@@ -190,8 +190,8 @@
"TITLE": "Chi Siamo", "TITLE": "Chi Siamo",
"EYEBROW": "Laboratorio di stampa 3D", "EYEBROW": "Laboratorio di stampa 3D",
"SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.", "SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.",
"HOW_TEXT": "3D Fab nasce dallinteresse 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.", "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": "Le nostre passioni", "PASSIONS_TITLE": "I nostri interessi",
"PASSION_BIKE_TRIAL": "Bike trial", "PASSION_BIKE_TRIAL": "Bike trial",
"PASSION_MOUNTAIN": "Montagna", "PASSION_MOUNTAIN": "Montagna",
"PASSION_SKI": "Ski", "PASSION_SKI": "Ski",