feat/calculator-options #26
@@ -1,421 +1,217 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.entity.FilamentMaterialType;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.dto.PrintSettingsDto;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.model.ModelDimensions;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.repository.FilamentMaterialTypeRepository;
|
||||
import com.printcalculator.repository.FilamentVariantRepository;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.service.OrcaProfileResolver;
|
||||
import com.printcalculator.service.NozzleLayerHeightPolicyService;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.QuoteSessionTotalsService;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.storage.ClamAVService;
|
||||
import com.printcalculator.service.quote.QuoteSessionItemService;
|
||||
import com.printcalculator.service.quote.QuoteSessionResponseAssembler;
|
||||
import com.printcalculator.service.quote.QuoteStorageService;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.Optional;
|
||||
import java.util.Locale;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/quote-sessions")
|
||||
|
||||
public class QuoteSessionController {
|
||||
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
||||
|
||||
private final QuoteSessionRepository sessionRepo;
|
||||
private final QuoteLineItemRepository lineItemRepo;
|
||||
private final SlicerService slicerService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
private final FilamentMaterialTypeRepository materialRepo;
|
||||
private final FilamentVariantRepository variantRepo;
|
||||
private final OrcaProfileResolver orcaProfileResolver;
|
||||
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
|
||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||
private final ClamAVService clamAVService;
|
||||
private final QuoteSessionTotalsService quoteSessionTotalsService;
|
||||
private final QuoteSessionItemService quoteSessionItemService;
|
||||
private final QuoteStorageService quoteStorageService;
|
||||
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
|
||||
|
||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||
QuoteLineItemRepository lineItemRepo,
|
||||
SlicerService slicerService,
|
||||
QuoteCalculator quoteCalculator,
|
||||
PrinterMachineRepository machineRepo,
|
||||
FilamentMaterialTypeRepository materialRepo,
|
||||
FilamentVariantRepository variantRepo,
|
||||
OrcaProfileResolver orcaProfileResolver,
|
||||
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService,
|
||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||
ClamAVService clamAVService,
|
||||
QuoteSessionTotalsService quoteSessionTotalsService) {
|
||||
QuoteSessionTotalsService quoteSessionTotalsService,
|
||||
QuoteSessionItemService quoteSessionItemService,
|
||||
QuoteStorageService quoteStorageService,
|
||||
QuoteSessionResponseAssembler quoteSessionResponseAssembler) {
|
||||
this.sessionRepo = sessionRepo;
|
||||
this.lineItemRepo = lineItemRepo;
|
||||
this.slicerService = slicerService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
this.materialRepo = materialRepo;
|
||||
this.variantRepo = variantRepo;
|
||||
this.orcaProfileResolver = orcaProfileResolver;
|
||||
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
|
||||
this.pricingRepo = pricingRepo;
|
||||
this.clamAVService = clamAVService;
|
||||
this.quoteSessionTotalsService = quoteSessionTotalsService;
|
||||
this.quoteSessionItemService = quoteSessionItemService;
|
||||
this.quoteStorageService = quoteStorageService;
|
||||
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
|
||||
}
|
||||
|
||||
// 1. Start a new empty session
|
||||
@PostMapping(value = "")
|
||||
@Transactional
|
||||
public ResponseEntity<QuoteSession> createSession() {
|
||||
QuoteSession session = new QuoteSession();
|
||||
session.setStatus("ACTIVE");
|
||||
session.setPricingVersion("v1");
|
||||
// Default material/settings will be set when items are added or updated?
|
||||
// For now set safe defaults
|
||||
session.setMaterialCode("PLA");
|
||||
session.setMaterialCode("PLA");
|
||||
session.setSupportsEnabled(false);
|
||||
session.setCreatedAt(OffsetDateTime.now());
|
||||
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||
|
||||
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||
|
||||
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||
session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy));
|
||||
|
||||
|
||||
session = sessionRepo.save(session);
|
||||
return ResponseEntity.ok(session);
|
||||
}
|
||||
|
||||
// 2. Add item to existing session
|
||||
|
||||
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Transactional
|
||||
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
|
||||
@PathVariable UUID id,
|
||||
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings,
|
||||
@RequestPart("file") MultipartFile file
|
||||
) throws IOException {
|
||||
public ResponseEntity<QuoteLineItem> addItemToExistingSession(@PathVariable UUID id,
|
||||
@RequestPart("settings") PrintSettingsDto settings,
|
||||
@RequestPart("file") MultipartFile file) throws IOException {
|
||||
QuoteSession session = sessionRepo.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||
|
||||
QuoteLineItem item = addItemToSession(session, file, settings);
|
||||
QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings);
|
||||
return ResponseEntity.ok(item);
|
||||
}
|
||||
|
||||
// Helper to add item
|
||||
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
||||
if (file.isEmpty()) throw new IllegalArgumentException("File is empty");
|
||||
@PatchMapping("/line-items/{lineItemId}")
|
||||
@Transactional
|
||||
public ResponseEntity<QuoteLineItem> updateLineItem(@PathVariable UUID lineItemId,
|
||||
@RequestBody Map<String, Object> updates) {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
QuoteSession session = item.getQuoteSession();
|
||||
if ("CONVERTED".equals(session.getStatus())) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
|
||||
}
|
||||
|
||||
// Scan for virus
|
||||
clamAVService.scan(file.getInputStream());
|
||||
|
||||
// 1. Define Persistent Storage Path
|
||||
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
|
||||
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize();
|
||||
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
|
||||
throw new IOException("Invalid quote session storage path");
|
||||
if (updates.containsKey("quantity")) {
|
||||
item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
|
||||
}
|
||||
Files.createDirectories(sessionStorageDir);
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String ext = getSafeExtension(originalFilename, "stl");
|
||||
String storedFilename = UUID.randomUUID() + "." + ext;
|
||||
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize();
|
||||
if (!persistentPath.startsWith(sessionStorageDir)) {
|
||||
throw new IOException("Invalid quote line-item storage path");
|
||||
if (updates.containsKey("color_code")) {
|
||||
Object colorValue = updates.get("color_code");
|
||||
if (colorValue != null) {
|
||||
item.setColorCode(String.valueOf(colorValue));
|
||||
}
|
||||
}
|
||||
|
||||
// Save file
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
Path convertedPersistentPath = null;
|
||||
try {
|
||||
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus());
|
||||
|
||||
// In CAD sessions, print settings are locked server-side.
|
||||
if (cadSession) {
|
||||
enforceCadPrintSettings(session, settings);
|
||||
} else {
|
||||
applyPrintSettings(settings);
|
||||
}
|
||||
|
||||
BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle(
|
||||
settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null
|
||||
);
|
||||
BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer(
|
||||
settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null,
|
||||
nozzleDiameter
|
||||
);
|
||||
if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) {
|
||||
throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
|
||||
+ " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString()
|
||||
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter)
|
||||
);
|
||||
}
|
||||
settings.setNozzleDiameter(nozzleDiameter.doubleValue());
|
||||
settings.setLayerHeight(layerHeight.doubleValue());
|
||||
|
||||
// Pick machine (selected machine if provided, otherwise first active)
|
||||
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId());
|
||||
|
||||
// Resolve selected filament variant
|
||||
FilamentVariant selectedVariant = resolveFilamentVariant(settings);
|
||||
|
||||
if (cadSession
|
||||
&& session.getMaterialCode() != null
|
||||
&& selectedVariant.getFilamentMaterialType() != null
|
||||
&& selectedVariant.getFilamentMaterialType().getMaterialCode() != null) {
|
||||
String lockedMaterial = normalizeRequestedMaterialCode(session.getMaterialCode());
|
||||
String selectedMaterial = normalizeRequestedMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
|
||||
if (!lockedMaterial.equals(selectedMaterial)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Selected filament does not match locked CAD material");
|
||||
}
|
||||
}
|
||||
|
||||
// Update session global settings from the most recent item added
|
||||
if (!cadSession) {
|
||||
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
|
||||
session.setNozzleDiameterMm(nozzleDiameter);
|
||||
session.setLayerHeightMm(layerHeight);
|
||||
session.setInfillPattern(settings.getInfillPattern());
|
||||
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
|
||||
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
|
||||
sessionRepo.save(session);
|
||||
}
|
||||
|
||||
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
|
||||
String machineProfile = profiles.machineProfileName();
|
||||
String filamentProfile = profiles.filamentProfileName();
|
||||
|
||||
String processProfile = "standard";
|
||||
if (settings.getLayerHeight() != null) {
|
||||
if (settings.getLayerHeight() >= 0.28) processProfile = "draft";
|
||||
else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine";
|
||||
}
|
||||
|
||||
// Build overrides map from settings
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString());
|
||||
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||
|
||||
Path slicerInputPath = persistentPath;
|
||||
if ("3mf".equals(ext)) {
|
||||
String convertedFilename = UUID.randomUUID() + "-converted.stl";
|
||||
convertedPersistentPath = sessionStorageDir.resolve(convertedFilename).normalize();
|
||||
if (!convertedPersistentPath.startsWith(sessionStorageDir)) {
|
||||
throw new IOException("Invalid converted STL storage path");
|
||||
}
|
||||
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
|
||||
slicerInputPath = convertedPersistentPath;
|
||||
}
|
||||
|
||||
// 3. Slice (Use persistent path)
|
||||
PrintStats stats = slicerService.slice(
|
||||
slicerInputPath.toFile(),
|
||||
machineProfile,
|
||||
filamentProfile,
|
||||
processProfile,
|
||||
null, // machine overrides
|
||||
processOverrides
|
||||
);
|
||||
|
||||
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
|
||||
|
||||
// 4. Calculate Quote
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
|
||||
|
||||
// 5. Create Line Item
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setOriginalFilename(file.getOriginalFilename());
|
||||
item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root)
|
||||
item.setQuantity(1);
|
||||
item.setColorCode(selectedVariant.getColorName());
|
||||
item.setFilamentVariant(selectedVariant);
|
||||
item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null
|
||||
? selectedVariant.getFilamentMaterialType().getMaterialCode()
|
||||
: normalizeRequestedMaterialCode(settings.getMaterial()));
|
||||
item.setQuality(resolveQuality(settings, layerHeight));
|
||||
item.setNozzleDiameterMm(nozzleDiameter);
|
||||
item.setLayerHeightMm(layerHeight);
|
||||
item.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
|
||||
item.setInfillPattern(settings.getInfillPattern());
|
||||
item.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
|
||||
item.setStatus("READY"); // or CALCULATED
|
||||
|
||||
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
|
||||
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
|
||||
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
||||
|
||||
// Store breakdown
|
||||
Map<String, Object> breakdown = new HashMap<>();
|
||||
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
|
||||
breakdown.put("setup_fee", 0);
|
||||
if (convertedPersistentPath != null) {
|
||||
breakdown.put("convertedStoredPath", QUOTE_STORAGE_ROOT.relativize(convertedPersistentPath).toString());
|
||||
}
|
||||
item.setPricingBreakdown(breakdown);
|
||||
|
||||
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
|
||||
item.setBoundingBoxXMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.xMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxYMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.yMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxZMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.zMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
|
||||
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
|
||||
return lineItemRepo.save(item);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Cleanup if failed
|
||||
Files.deleteIfExists(persistentPath);
|
||||
if (convertedPersistentPath != null) {
|
||||
Files.deleteIfExists(convertedPersistentPath);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
return ResponseEntity.ok(lineItemRepo.save(item));
|
||||
}
|
||||
|
||||
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
||||
if (settings.getNozzleDiameter() == null) {
|
||||
settings.setNozzleDiameter(0.40);
|
||||
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> deleteLineItem(@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId) {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||
// Set defaults based on Quality
|
||||
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
|
||||
|
||||
switch (quality) {
|
||||
case "draft":
|
||||
settings.setLayerHeight(0.28);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
break;
|
||||
case "extra_fine":
|
||||
case "high_definition":
|
||||
case "high":
|
||||
settings.setLayerHeight(0.12);
|
||||
settings.setInfillDensity(20.0);
|
||||
settings.setInfillPattern("gyroid");
|
||||
break;
|
||||
case "standard":
|
||||
default:
|
||||
settings.setLayerHeight(0.20);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// ADVANCED Mode: Use values from Frontend, set defaults if missing
|
||||
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
|
||||
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
|
||||
}
|
||||
lineItemRepo.delete(item);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.PrintSettingsDto settings) {
|
||||
settings.setComplexityMode("ADVANCED");
|
||||
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
|
||||
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
|
||||
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
|
||||
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
|
||||
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
|
||||
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
||||
QuoteSession session = sessionRepo.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||
|
||||
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
|
||||
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
|
||||
return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals));
|
||||
}
|
||||
|
||||
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;
|
||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
||||
public ResponseEntity<Resource> downloadLineItemContent(@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId,
|
||||
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview)
|
||||
throws IOException {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
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 = normalizeRequestedMaterialCode(settings.getMaterial());
|
||||
|
||||
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();
|
||||
String targetStoredPath = item.getStoredPath();
|
||||
if (preview) {
|
||||
String convertedPath = quoteStorageService.extractConvertedStoredPath(item);
|
||||
if (convertedPath != null && !convertedPath.isBlank()) {
|
||||
targetStoredPath = convertedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
|
||||
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
|
||||
}
|
||||
|
||||
private String normalizeRequestedMaterialCode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "PLA";
|
||||
if (targetStoredPath == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
.toUpperCase(Locale.ROOT)
|
||||
.replace('_', ' ')
|
||||
.replace('-', ' ')
|
||||
.replaceAll("\\s+", " ");
|
||||
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||
if (path == null || !java.nio.file.Files.exists(path)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
|
||||
public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId)
|
||||
throws IOException {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String targetStoredPath = item.getStoredPath();
|
||||
if (targetStoredPath == null || targetStoredPath.isBlank()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||
if (path == null || !java.nio.file.Files.exists(path)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
String downloadName = path.getFileName().toString();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("model/stl"))
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
private int parsePositiveQuantity(Object raw) {
|
||||
@@ -443,262 +239,4 @@ public class QuoteSessionController {
|
||||
}
|
||||
return quantity;
|
||||
}
|
||||
|
||||
// 3. Update Line Item
|
||||
@PatchMapping("/line-items/{lineItemId}")
|
||||
@Transactional
|
||||
public ResponseEntity<QuoteLineItem> updateLineItem(
|
||||
@PathVariable UUID lineItemId,
|
||||
@RequestBody Map<String, Object> updates
|
||||
) {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
QuoteSession session = item.getQuoteSession();
|
||||
if ("CONVERTED".equals(session.getStatus())) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
|
||||
}
|
||||
|
||||
if (updates.containsKey("quantity")) {
|
||||
item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
|
||||
}
|
||||
if (updates.containsKey("color_code")) {
|
||||
Object colorValue = updates.get("color_code");
|
||||
if (colorValue != null) {
|
||||
item.setColorCode(String.valueOf(colorValue));
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate price if needed?
|
||||
// For now, unit price is fixed in mock. Total is calculated on GET.
|
||||
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
return ResponseEntity.ok(lineItemRepo.save(item));
|
||||
}
|
||||
|
||||
// 4. Delete Line Item
|
||||
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> deleteLineItem(
|
||||
@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId
|
||||
) {
|
||||
// Verify item belongs to session?
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
lineItemRepo.delete(item);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// 5. Get Session (Session + Items + Total)
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
||||
QuoteSession session = sessionRepo.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||
|
||||
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
|
||||
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
|
||||
|
||||
// Map items to DTO to embed distributed machine cost
|
||||
List<Map<String, Object>> itemsDto = new ArrayList<>();
|
||||
for (QuoteLineItem item : items) {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", item.getId());
|
||||
dto.put("originalFilename", item.getOriginalFilename());
|
||||
dto.put("quantity", item.getQuantity());
|
||||
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
|
||||
dto.put("materialGrams", item.getMaterialGrams());
|
||||
dto.put("colorCode", item.getColorCode());
|
||||
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
||||
dto.put("materialCode", item.getMaterialCode());
|
||||
dto.put("quality", item.getQuality());
|
||||
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
|
||||
dto.put("layerHeightMm", item.getLayerHeightMm());
|
||||
dto.put("infillPercent", item.getInfillPercent());
|
||||
dto.put("infillPattern", item.getInfillPattern());
|
||||
dto.put("supportsEnabled", item.getSupportsEnabled());
|
||||
dto.put("status", item.getStatus());
|
||||
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
|
||||
|
||||
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
|
||||
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1;
|
||||
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
||||
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
|
||||
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
|
||||
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
|
||||
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
|
||||
unitPrice = unitPrice.add(unitMachineCost);
|
||||
}
|
||||
dto.put("unitPriceChf", unitPrice);
|
||||
itemsDto.add(dto);
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("session", session);
|
||||
response.put("items", itemsDto);
|
||||
response.put("printItemsTotalChf", totals.printItemsTotalChf());
|
||||
response.put("cadTotalChf", totals.cadTotalChf());
|
||||
response.put("itemsTotalChf", totals.itemsTotalChf());
|
||||
response.put("shippingCostChf", totals.shippingCostChf());
|
||||
response.put("globalMachineCostChf", totals.globalMachineCostChf());
|
||||
response.put("grandTotalChf", totals.grandTotalChf());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// 6. Download Line Item Content
|
||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
|
||||
@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId,
|
||||
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
|
||||
) throws IOException {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String targetStoredPath = item.getStoredPath();
|
||||
if (preview) {
|
||||
String convertedPath = extractConvertedStoredPath(item);
|
||||
if (convertedPath != null && !convertedPath.isBlank()) {
|
||||
targetStoredPath = convertedPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetStoredPath == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
|
||||
String downloadName = item.getOriginalFilename();
|
||||
if (preview) {
|
||||
downloadName = path.getFileName().toString();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
// 7. Download STL preview for checkout (only when original file is STL)
|
||||
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
|
||||
public ResponseEntity<Resource> downloadLineItemStlPreview(
|
||||
@PathVariable UUID sessionId,
|
||||
@PathVariable UUID lineItemId
|
||||
) throws IOException {
|
||||
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||
|
||||
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
// Only expose preview for native STL uploads.
|
||||
if (!"stl".equals(getSafeExtension(item.getOriginalFilename(), ""))) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String targetStoredPath = item.getStoredPath();
|
||||
if (targetStoredPath == null || targetStoredPath.isBlank()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!"stl".equals(getSafeExtension(path.getFileName().toString(), ""))) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
String downloadName = path.getFileName().toString();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("model/stl"))
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
private String getSafeExtension(String filename, String fallback) {
|
||||
if (filename == null) {
|
||||
return fallback;
|
||||
}
|
||||
String cleaned = StringUtils.cleanPath(filename);
|
||||
if (cleaned.contains("..")) {
|
||||
return fallback;
|
||||
}
|
||||
int index = cleaned.lastIndexOf('.');
|
||||
if (index <= 0 || index >= cleaned.length() - 1) {
|
||||
return fallback;
|
||||
}
|
||||
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
|
||||
return switch (ext) {
|
||||
case "stl" -> "stl";
|
||||
case "3mf" -> "3mf";
|
||||
case "step", "stp" -> "step";
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
|
||||
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Path raw = Path.of(storedPath).normalize();
|
||||
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
|
||||
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
|
||||
if (!resolved.startsWith(expectedSessionRoot)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch (InvalidPathException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractConvertedStoredPath(QuoteLineItem item) {
|
||||
Map<String, Object> breakdown = item.getPricingBreakdown();
|
||||
if (breakdown == null) {
|
||||
return null;
|
||||
}
|
||||
Object converted = breakdown.get("convertedStoredPath");
|
||||
if (converted == null) {
|
||||
return null;
|
||||
}
|
||||
String path = String.valueOf(converted).trim();
|
||||
return path.isEmpty() ? null : path;
|
||||
}
|
||||
|
||||
private String resolveQuality(com.printcalculator.dto.PrintSettingsDto settings, BigDecimal layerHeight) {
|
||||
if (settings.getQuality() != null && !settings.getQuality().isBlank()) {
|
||||
return settings.getQuality().trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
if (layerHeight == null) {
|
||||
return "standard";
|
||||
}
|
||||
if (layerHeight.compareTo(BigDecimal.valueOf(0.24)) >= 0) {
|
||||
return "draft";
|
||||
}
|
||||
if (layerHeight.compareTo(BigDecimal.valueOf(0.12)) <= 0) {
|
||||
return "extra_fine";
|
||||
}
|
||||
return "standard";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PrintSettingsDto {
|
||||
// Mode: "BASIC" or "ADVANCED"
|
||||
private String complexityMode;
|
||||
@@ -28,4 +25,124 @@ public class PrintSettingsDto {
|
||||
private Double boundingBoxX;
|
||||
private Double boundingBoxY;
|
||||
private Double boundingBoxZ;
|
||||
|
||||
public String getComplexityMode() {
|
||||
return complexityMode;
|
||||
}
|
||||
|
||||
public void setComplexityMode(String complexityMode) {
|
||||
this.complexityMode = complexityMode;
|
||||
}
|
||||
|
||||
public String getMaterial() {
|
||||
return material;
|
||||
}
|
||||
|
||||
public void setMaterial(String material) {
|
||||
this.material = material;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public Long getFilamentVariantId() {
|
||||
return filamentVariantId;
|
||||
}
|
||||
|
||||
public void setFilamentVariantId(Long filamentVariantId) {
|
||||
this.filamentVariantId = filamentVariantId;
|
||||
}
|
||||
|
||||
public Long getPrinterMachineId() {
|
||||
return printerMachineId;
|
||||
}
|
||||
|
||||
public void setPrinterMachineId(Long printerMachineId) {
|
||||
this.printerMachineId = printerMachineId;
|
||||
}
|
||||
|
||||
public String getQuality() {
|
||||
return quality;
|
||||
}
|
||||
|
||||
public void setQuality(String quality) {
|
||||
this.quality = quality;
|
||||
}
|
||||
|
||||
public Double getNozzleDiameter() {
|
||||
return nozzleDiameter;
|
||||
}
|
||||
|
||||
public void setNozzleDiameter(Double nozzleDiameter) {
|
||||
this.nozzleDiameter = nozzleDiameter;
|
||||
}
|
||||
|
||||
public Double getLayerHeight() {
|
||||
return layerHeight;
|
||||
}
|
||||
|
||||
public void setLayerHeight(Double layerHeight) {
|
||||
this.layerHeight = layerHeight;
|
||||
}
|
||||
|
||||
public Double getInfillDensity() {
|
||||
return infillDensity;
|
||||
}
|
||||
|
||||
public void setInfillDensity(Double infillDensity) {
|
||||
this.infillDensity = infillDensity;
|
||||
}
|
||||
|
||||
public String getInfillPattern() {
|
||||
return infillPattern;
|
||||
}
|
||||
|
||||
public void setInfillPattern(String infillPattern) {
|
||||
this.infillPattern = infillPattern;
|
||||
}
|
||||
|
||||
public Boolean getSupportsEnabled() {
|
||||
return supportsEnabled;
|
||||
}
|
||||
|
||||
public void setSupportsEnabled(Boolean supportsEnabled) {
|
||||
this.supportsEnabled = supportsEnabled;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
public void setNotes(String notes) {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
public Double getBoundingBoxX() {
|
||||
return boundingBoxX;
|
||||
}
|
||||
|
||||
public void setBoundingBoxX(Double boundingBoxX) {
|
||||
this.boundingBoxX = boundingBoxX;
|
||||
}
|
||||
|
||||
public Double getBoundingBoxY() {
|
||||
return boundingBoxY;
|
||||
}
|
||||
|
||||
public void setBoundingBoxY(Double boundingBoxY) {
|
||||
this.boundingBoxY = boundingBoxY;
|
||||
}
|
||||
|
||||
public Double getBoundingBoxZ() {
|
||||
return boundingBoxZ;
|
||||
}
|
||||
|
||||
public void setBoundingBoxZ(Double boundingBoxZ) {
|
||||
this.boundingBoxZ = boundingBoxZ;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,36 +51,18 @@ public class QuoteLineItem {
|
||||
@Column(name = "quality", length = Integer.MAX_VALUE)
|
||||
private String quality;
|
||||
|
||||
@Column(name = "nozzle_diameter_mm", precision = 4, scale = 2)
|
||||
private BigDecimal nozzleDiameterMm;
|
||||
|
||||
@Column(name = "layer_height_mm", precision = 5, scale = 3)
|
||||
private BigDecimal layerHeightMm;
|
||||
|
||||
@Column(name = "infill_percent")
|
||||
private Integer infillPercent;
|
||||
|
||||
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
|
||||
private String infillPattern;
|
||||
|
||||
@Column(name = "supports_enabled")
|
||||
private Boolean supportsEnabled;
|
||||
|
||||
@Column(name = "material_code", length = Integer.MAX_VALUE)
|
||||
private String materialCode;
|
||||
|
||||
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
|
||||
private BigDecimal nozzleDiameterMm;
|
||||
|
||||
@Column(name = "layer_height_mm", precision = 6, scale = 3)
|
||||
private BigDecimal layerHeightMm;
|
||||
|
||||
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
|
||||
private String infillPattern;
|
||||
|
||||
@Column(name = "infill_percent")
|
||||
private Integer infillPercent;
|
||||
|
||||
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
|
||||
private String infillPattern;
|
||||
|
||||
@Column(name = "supports_enabled")
|
||||
private Boolean supportsEnabled;
|
||||
|
||||
@@ -232,54 +214,6 @@ public class QuoteLineItem {
|
||||
this.supportsEnabled = supportsEnabled;
|
||||
}
|
||||
|
||||
public String getMaterialCode() {
|
||||
return materialCode;
|
||||
}
|
||||
|
||||
public void setMaterialCode(String materialCode) {
|
||||
this.materialCode = materialCode;
|
||||
}
|
||||
|
||||
public BigDecimal getNozzleDiameterMm() {
|
||||
return nozzleDiameterMm;
|
||||
}
|
||||
|
||||
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
|
||||
this.nozzleDiameterMm = nozzleDiameterMm;
|
||||
}
|
||||
|
||||
public BigDecimal getLayerHeightMm() {
|
||||
return layerHeightMm;
|
||||
}
|
||||
|
||||
public void setLayerHeightMm(BigDecimal layerHeightMm) {
|
||||
this.layerHeightMm = layerHeightMm;
|
||||
}
|
||||
|
||||
public String getInfillPattern() {
|
||||
return infillPattern;
|
||||
}
|
||||
|
||||
public void setInfillPattern(String infillPattern) {
|
||||
this.infillPattern = infillPattern;
|
||||
}
|
||||
|
||||
public Integer getInfillPercent() {
|
||||
return infillPercent;
|
||||
}
|
||||
|
||||
public void setInfillPercent(Integer infillPercent) {
|
||||
this.infillPercent = infillPercent;
|
||||
}
|
||||
|
||||
public Boolean getSupportsEnabled() {
|
||||
return supportsEnabled;
|
||||
}
|
||||
|
||||
public void setSupportsEnabled(Boolean supportsEnabled) {
|
||||
this.supportsEnabled = supportsEnabled;
|
||||
}
|
||||
|
||||
public BigDecimal getBoundingBoxXMm() {
|
||||
return boundingBoxXMm;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.printcalculator.service.quote;
|
||||
|
||||
import com.printcalculator.dto.PrintSettingsDto;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.model.ModelDimensions;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.service.OrcaProfileResolver;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.storage.ClamAVService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@Service
|
||||
public class QuoteSessionItemService {
|
||||
private final QuoteLineItemRepository lineItemRepo;
|
||||
private final QuoteSessionRepository sessionRepo;
|
||||
private final SlicerService slicerService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final OrcaProfileResolver orcaProfileResolver;
|
||||
private final ClamAVService clamAVService;
|
||||
private final QuoteStorageService quoteStorageService;
|
||||
private final QuoteSessionSettingsService settingsService;
|
||||
|
||||
public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo,
|
||||
QuoteSessionRepository sessionRepo,
|
||||
SlicerService slicerService,
|
||||
QuoteCalculator quoteCalculator,
|
||||
OrcaProfileResolver orcaProfileResolver,
|
||||
ClamAVService clamAVService,
|
||||
QuoteStorageService quoteStorageService,
|
||||
QuoteSessionSettingsService settingsService) {
|
||||
this.lineItemRepo = lineItemRepo;
|
||||
this.sessionRepo = sessionRepo;
|
||||
this.slicerService = slicerService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.orcaProfileResolver = orcaProfileResolver;
|
||||
this.clamAVService = clamAVService;
|
||||
this.quoteStorageService = quoteStorageService;
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException {
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File is empty");
|
||||
}
|
||||
if ("CONVERTED".equals(session.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
|
||||
}
|
||||
|
||||
clamAVService.scan(file.getInputStream());
|
||||
|
||||
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
|
||||
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
|
||||
String storedFilename = UUID.randomUUID() + "." + ext;
|
||||
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
|
||||
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
Path convertedPersistentPath = null;
|
||||
try {
|
||||
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus());
|
||||
|
||||
if (cadSession) {
|
||||
settingsService.enforceCadPrintSettings(session, settings);
|
||||
} else {
|
||||
settingsService.applyPrintSettings(settings);
|
||||
}
|
||||
|
||||
QuoteSessionSettingsService.NozzleLayerSettings nozzleAndLayer = settingsService.resolveNozzleAndLayer(settings);
|
||||
BigDecimal nozzleDiameter = nozzleAndLayer.nozzleDiameter();
|
||||
BigDecimal layerHeight = nozzleAndLayer.layerHeight();
|
||||
|
||||
PrinterMachine machine = settingsService.resolvePrinterMachine(settings.getPrinterMachineId());
|
||||
FilamentVariant selectedVariant = settingsService.resolveFilamentVariant(settings);
|
||||
|
||||
validateCadMaterialLock(session, cadSession, selectedVariant);
|
||||
|
||||
if (!cadSession) {
|
||||
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
|
||||
session.setNozzleDiameterMm(nozzleDiameter);
|
||||
session.setLayerHeightMm(layerHeight);
|
||||
session.setInfillPattern(settings.getInfillPattern());
|
||||
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
|
||||
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
|
||||
sessionRepo.save(session);
|
||||
}
|
||||
|
||||
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
|
||||
String processProfile = resolveProcessProfile(settings);
|
||||
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString());
|
||||
if (settings.getInfillDensity() != null) {
|
||||
processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||
}
|
||||
if (settings.getInfillPattern() != null) {
|
||||
processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||
}
|
||||
|
||||
Path slicerInputPath = persistentPath;
|
||||
if ("3mf".equals(ext)) {
|
||||
String convertedFilename = UUID.randomUUID() + "-converted.stl";
|
||||
convertedPersistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, convertedFilename);
|
||||
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
|
||||
slicerInputPath = convertedPersistentPath;
|
||||
}
|
||||
|
||||
PrintStats stats = slicerService.slice(
|
||||
slicerInputPath.toFile(),
|
||||
profiles.machineProfileName(),
|
||||
profiles.filamentProfileName(),
|
||||
processProfile,
|
||||
null,
|
||||
processOverrides
|
||||
);
|
||||
|
||||
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
|
||||
|
||||
QuoteLineItem item = buildLineItem(
|
||||
session,
|
||||
file.getOriginalFilename(),
|
||||
settings,
|
||||
selectedVariant,
|
||||
nozzleDiameter,
|
||||
layerHeight,
|
||||
stats,
|
||||
result,
|
||||
modelDimensions,
|
||||
persistentPath,
|
||||
convertedPersistentPath
|
||||
);
|
||||
|
||||
return lineItemRepo.save(item);
|
||||
} catch (Exception e) {
|
||||
Files.deleteIfExists(persistentPath);
|
||||
if (convertedPersistentPath != null) {
|
||||
Files.deleteIfExists(convertedPersistentPath);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCadMaterialLock(QuoteSession session, boolean cadSession, FilamentVariant selectedVariant) {
|
||||
if (!cadSession
|
||||
|| session.getMaterialCode() == null
|
||||
|| selectedVariant.getFilamentMaterialType() == null
|
||||
|| selectedVariant.getFilamentMaterialType().getMaterialCode() == null) {
|
||||
return;
|
||||
}
|
||||
String lockedMaterial = settingsService.normalizeRequestedMaterialCode(session.getMaterialCode());
|
||||
String selectedMaterial = settingsService.normalizeRequestedMaterialCode(
|
||||
selectedVariant.getFilamentMaterialType().getMaterialCode()
|
||||
);
|
||||
if (!lockedMaterial.equals(selectedMaterial)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected filament does not match locked CAD material");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveProcessProfile(PrintSettingsDto settings) {
|
||||
if (settings.getLayerHeight() == null) {
|
||||
return "standard";
|
||||
}
|
||||
if (settings.getLayerHeight() >= 0.28) {
|
||||
return "draft";
|
||||
}
|
||||
if (settings.getLayerHeight() <= 0.12) {
|
||||
return "extra_fine";
|
||||
}
|
||||
return "standard";
|
||||
}
|
||||
|
||||
private QuoteLineItem buildLineItem(QuoteSession session,
|
||||
String originalFilename,
|
||||
PrintSettingsDto settings,
|
||||
FilamentVariant selectedVariant,
|
||||
BigDecimal nozzleDiameter,
|
||||
BigDecimal layerHeight,
|
||||
PrintStats stats,
|
||||
QuoteResult result,
|
||||
Optional<ModelDimensions> modelDimensions,
|
||||
Path persistentPath,
|
||||
Path convertedPersistentPath) {
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setOriginalFilename(originalFilename);
|
||||
item.setStoredPath(quoteStorageService.toStoredPath(persistentPath));
|
||||
item.setQuantity(1);
|
||||
item.setColorCode(selectedVariant.getColorName());
|
||||
item.setFilamentVariant(selectedVariant);
|
||||
item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null
|
||||
? selectedVariant.getFilamentMaterialType().getMaterialCode()
|
||||
: settingsService.normalizeRequestedMaterialCode(settings.getMaterial()));
|
||||
item.setQuality(settingsService.resolveQuality(settings, layerHeight));
|
||||
item.setNozzleDiameterMm(nozzleDiameter);
|
||||
item.setLayerHeightMm(layerHeight);
|
||||
item.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
|
||||
item.setInfillPattern(settings.getInfillPattern());
|
||||
item.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
|
||||
item.setStatus("READY");
|
||||
|
||||
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
|
||||
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
|
||||
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
||||
|
||||
Map<String, Object> breakdown = new HashMap<>();
|
||||
breakdown.put("machine_cost", result.getTotalPrice());
|
||||
breakdown.put("setup_fee", 0);
|
||||
if (convertedPersistentPath != null) {
|
||||
breakdown.put("convertedStoredPath", quoteStorageService.toStoredPath(convertedPersistentPath));
|
||||
}
|
||||
item.setPricingBreakdown(breakdown);
|
||||
|
||||
item.setBoundingBoxXMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.xMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxYMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.yMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
|
||||
item.setBoundingBoxZMm(modelDimensions
|
||||
.map(dim -> BigDecimal.valueOf(dim.zMm()))
|
||||
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
|
||||
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.printcalculator.service.quote;
|
||||
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.service.QuoteSessionTotalsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class QuoteSessionResponseAssembler {
|
||||
private final QuoteStorageService quoteStorageService;
|
||||
|
||||
public QuoteSessionResponseAssembler(QuoteStorageService quoteStorageService) {
|
||||
this.quoteStorageService = quoteStorageService;
|
||||
}
|
||||
|
||||
public Map<String, Object> assemble(QuoteSession session,
|
||||
List<QuoteLineItem> items,
|
||||
QuoteSessionTotalsService.QuoteSessionTotals totals) {
|
||||
List<Map<String, Object>> itemsDto = new ArrayList<>();
|
||||
for (QuoteLineItem item : items) {
|
||||
itemsDto.add(toItemDto(item, totals));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("session", session);
|
||||
response.put("items", itemsDto);
|
||||
response.put("printItemsTotalChf", totals.printItemsTotalChf());
|
||||
response.put("cadTotalChf", totals.cadTotalChf());
|
||||
response.put("itemsTotalChf", totals.itemsTotalChf());
|
||||
response.put("shippingCostChf", totals.shippingCostChf());
|
||||
response.put("globalMachineCostChf", totals.globalMachineCostChf());
|
||||
response.put("grandTotalChf", totals.grandTotalChf());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", item.getId());
|
||||
dto.put("originalFilename", item.getOriginalFilename());
|
||||
dto.put("quantity", item.getQuantity());
|
||||
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
|
||||
dto.put("materialGrams", item.getMaterialGrams());
|
||||
dto.put("colorCode", item.getColorCode());
|
||||
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
|
||||
dto.put("materialCode", item.getMaterialCode());
|
||||
dto.put("quality", item.getQuality());
|
||||
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
|
||||
dto.put("layerHeightMm", item.getLayerHeightMm());
|
||||
dto.put("infillPercent", item.getInfillPercent());
|
||||
dto.put("infillPattern", item.getInfillPattern());
|
||||
dto.put("supportsEnabled", item.getSupportsEnabled());
|
||||
dto.put("status", item.getStatus());
|
||||
dto.put("convertedStoredPath", quoteStorageService.extractConvertedStoredPath(item));
|
||||
dto.put("unitPriceChf", resolveDistributedUnitPrice(item, totals));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private BigDecimal resolveDistributedUnitPrice(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
|
||||
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
|
||||
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1;
|
||||
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
||||
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
|
||||
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
|
||||
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
|
||||
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
|
||||
unitPrice = unitPrice.add(unitMachineCost);
|
||||
}
|
||||
return unitPrice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.printcalculator.service.quote;
|
||||
|
||||
import com.printcalculator.dto.PrintSettingsDto;
|
||||
import com.printcalculator.entity.FilamentMaterialType;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.repository.FilamentMaterialTypeRepository;
|
||||
import com.printcalculator.repository.FilamentVariantRepository;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.NozzleLayerHeightPolicyService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class QuoteSessionSettingsService {
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
private final FilamentMaterialTypeRepository materialRepo;
|
||||
private final FilamentVariantRepository variantRepo;
|
||||
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
|
||||
|
||||
public QuoteSessionSettingsService(PrinterMachineRepository machineRepo,
|
||||
FilamentMaterialTypeRepository materialRepo,
|
||||
FilamentVariantRepository variantRepo,
|
||||
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
|
||||
this.machineRepo = machineRepo;
|
||||
this.materialRepo = materialRepo;
|
||||
this.variantRepo = variantRepo;
|
||||
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
|
||||
}
|
||||
|
||||
public void applyPrintSettings(PrintSettingsDto settings) {
|
||||
if (settings.getNozzleDiameter() == null) {
|
||||
settings.setNozzleDiameter(0.40);
|
||||
}
|
||||
|
||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
|
||||
|
||||
switch (quality) {
|
||||
case "draft" -> {
|
||||
settings.setLayerHeight(0.28);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
}
|
||||
case "extra_fine", "high_definition", "high" -> {
|
||||
settings.setLayerHeight(0.12);
|
||||
settings.setInfillDensity(20.0);
|
||||
settings.setInfillPattern("gyroid");
|
||||
}
|
||||
case "standard" -> {
|
||||
settings.setLayerHeight(0.20);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
}
|
||||
default -> {
|
||||
settings.setLayerHeight(0.20);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (settings.getInfillDensity() == null) {
|
||||
settings.setInfillDensity(20.0);
|
||||
}
|
||||
if (settings.getInfillPattern() == null) {
|
||||
settings.setInfillPattern("grid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void enforceCadPrintSettings(QuoteSession session, PrintSettingsDto settings) {
|
||||
settings.setComplexityMode("ADVANCED");
|
||||
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
|
||||
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
|
||||
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
|
||||
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
|
||||
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
|
||||
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
|
||||
}
|
||||
|
||||
public NozzleLayerSettings resolveNozzleAndLayer(PrintSettingsDto settings) {
|
||||
BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle(
|
||||
settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null
|
||||
);
|
||||
BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer(
|
||||
settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null,
|
||||
nozzleDiameter
|
||||
);
|
||||
if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
|
||||
+ " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString()
|
||||
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter)
|
||||
);
|
||||
}
|
||||
settings.setNozzleDiameter(nozzleDiameter.doubleValue());
|
||||
settings.setLayerHeight(layerHeight.doubleValue());
|
||||
return new NozzleLayerSettings(nozzleDiameter, layerHeight);
|
||||
}
|
||||
|
||||
public 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"));
|
||||
}
|
||||
|
||||
public FilamentVariant resolveFilamentVariant(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 = normalizeRequestedMaterialCode(settings.getMaterial());
|
||||
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));
|
||||
}
|
||||
|
||||
public String normalizeRequestedMaterialCode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "PLA";
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
.toUpperCase(Locale.ROOT)
|
||||
.replace('_', ' ')
|
||||
.replace('-', ' ')
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
public String resolveQuality(PrintSettingsDto settings, BigDecimal layerHeight) {
|
||||
if (settings.getQuality() != null && !settings.getQuality().isBlank()) {
|
||||
return settings.getQuality().trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
if (layerHeight == null) {
|
||||
return "standard";
|
||||
}
|
||||
if (layerHeight.compareTo(BigDecimal.valueOf(0.24)) >= 0) {
|
||||
return "draft";
|
||||
}
|
||||
if (layerHeight.compareTo(BigDecimal.valueOf(0.12)) <= 0) {
|
||||
return "extra_fine";
|
||||
}
|
||||
return "standard";
|
||||
}
|
||||
|
||||
public record NozzleLayerSettings(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.printcalculator.service.quote;
|
||||
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class QuoteStorageService {
|
||||
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
||||
|
||||
public Path sessionStorageDir(UUID sessionId) throws IOException {
|
||||
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(sessionId.toString()).normalize();
|
||||
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
|
||||
throw new IOException("Invalid quote session storage path");
|
||||
}
|
||||
Files.createDirectories(sessionStorageDir);
|
||||
return sessionStorageDir;
|
||||
}
|
||||
|
||||
public Path resolveSessionPath(Path sessionStorageDir, String filename) throws IOException {
|
||||
Path resolved = sessionStorageDir.resolve(filename).normalize();
|
||||
if (!resolved.startsWith(sessionStorageDir)) {
|
||||
throw new IOException("Invalid quote line-item storage path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public String toStoredPath(Path absolutePath) {
|
||||
return QUOTE_STORAGE_ROOT.relativize(absolutePath).toString();
|
||||
}
|
||||
|
||||
public String getSafeExtension(String filename, String fallback) {
|
||||
if (filename == null) {
|
||||
return fallback;
|
||||
}
|
||||
String cleaned = StringUtils.cleanPath(filename);
|
||||
if (cleaned.contains("..")) {
|
||||
return fallback;
|
||||
}
|
||||
int index = cleaned.lastIndexOf('.');
|
||||
if (index <= 0 || index >= cleaned.length() - 1) {
|
||||
return fallback;
|
||||
}
|
||||
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
|
||||
return switch (ext) {
|
||||
case "stl" -> "stl";
|
||||
case "3mf" -> "3mf";
|
||||
case "step", "stp" -> "step";
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
|
||||
public Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Path raw = Path.of(storedPath).normalize();
|
||||
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
|
||||
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
|
||||
if (!resolved.startsWith(expectedSessionRoot)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch (InvalidPathException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String extractConvertedStoredPath(QuoteLineItem item) {
|
||||
Map<String, Object> breakdown = item.getPricingBreakdown();
|
||||
if (breakdown == null) {
|
||||
return null;
|
||||
}
|
||||
Object converted = breakdown.get("convertedStoredPath");
|
||||
if (converted == null) {
|
||||
return null;
|
||||
}
|
||||
String path = String.valueOf(converted).trim();
|
||||
return path.isEmpty() ? null : path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user