diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 015cb8e..5f13e46 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -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 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 addItemToExistingSession( - @PathVariable UUID id, - @RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, - @RequestPart("file") MultipartFile file - ) throws IOException { + public ResponseEntity 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 updateLineItem(@PathVariable UUID lineItemId, + @RequestBody Map 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 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 = 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 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 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> getQuoteSession(@PathVariable UUID id) { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + List 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 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 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 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 updateLineItem( - @PathVariable UUID lineItemId, - @RequestBody Map 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 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> getQuoteSession(@PathVariable UUID id) { - QuoteSession session = sessionRepo.findById(id) - .orElseThrow(() -> new RuntimeException("Session not found")); - - List items = lineItemRepo.findByQuoteSessionId(id); - QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); - - // Map items to DTO to embed distributed machine cost - List> itemsDto = new ArrayList<>(); - for (QuoteLineItem item : items) { - Map 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 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 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 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 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"; - } } diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java index e01cfc6..1d2a4df 100644 --- a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -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; + } } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index 9849cab..4103f8c 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java new file mode 100644 index 0000000..9797c7f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -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 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 = 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, + 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 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java new file mode 100644 index 0000000..f5e8721 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -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 assemble(QuoteSession session, + List items, + QuoteSessionTotalsService.QuoteSessionTotals totals) { + List> itemsDto = new ArrayList<>(); + for (QuoteLineItem item : items) { + itemsDto.add(toItemDto(item, totals)); + } + + Map 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 toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) { + Map 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java new file mode 100644 index 0000000..c07cf08 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java @@ -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 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) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java new file mode 100644 index 0000000..87e5e44 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java @@ -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 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; + } +}