fix(tutto rotto): dai che si fixa
This commit is contained in:
@@ -1,108 +1,68 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
import com.printcalculator.entity.FilamentMaterialType;
|
import com.printcalculator.dto.PrintSettingsDto;
|
||||||
import com.printcalculator.entity.FilamentVariant;
|
|
||||||
import com.printcalculator.entity.PrinterMachine;
|
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
import com.printcalculator.entity.QuoteSession;
|
import com.printcalculator.entity.QuoteSession;
|
||||||
import com.printcalculator.model.ModelDimensions;
|
|
||||||
import com.printcalculator.model.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.QuoteLineItemRepository;
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
import com.printcalculator.service.OrcaProfileResolver;
|
|
||||||
import com.printcalculator.service.NozzleLayerHeightPolicyService;
|
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.QuoteSessionTotalsService;
|
import com.printcalculator.service.QuoteSessionTotalsService;
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.quote.QuoteSessionItemService;
|
||||||
import com.printcalculator.service.storage.ClamAVService;
|
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.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.io.IOException;
|
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.time.OffsetDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
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;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/quote-sessions")
|
@RequestMapping("/api/quote-sessions")
|
||||||
|
|
||||||
public class QuoteSessionController {
|
public class QuoteSessionController {
|
||||||
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
|
||||||
|
|
||||||
private final QuoteSessionRepository sessionRepo;
|
private final QuoteSessionRepository sessionRepo;
|
||||||
private final QuoteLineItemRepository lineItemRepo;
|
private final QuoteLineItemRepository lineItemRepo;
|
||||||
private final SlicerService slicerService;
|
|
||||||
private final QuoteCalculator quoteCalculator;
|
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 com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||||
private final ClamAVService clamAVService;
|
|
||||||
private final QuoteSessionTotalsService quoteSessionTotalsService;
|
private final QuoteSessionTotalsService quoteSessionTotalsService;
|
||||||
|
private final QuoteSessionItemService quoteSessionItemService;
|
||||||
|
private final QuoteStorageService quoteStorageService;
|
||||||
|
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
|
||||||
|
|
||||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||||
QuoteLineItemRepository lineItemRepo,
|
QuoteLineItemRepository lineItemRepo,
|
||||||
SlicerService slicerService,
|
|
||||||
QuoteCalculator quoteCalculator,
|
QuoteCalculator quoteCalculator,
|
||||||
PrinterMachineRepository machineRepo,
|
|
||||||
FilamentMaterialTypeRepository materialRepo,
|
|
||||||
FilamentVariantRepository variantRepo,
|
|
||||||
OrcaProfileResolver orcaProfileResolver,
|
|
||||||
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService,
|
|
||||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||||
ClamAVService clamAVService,
|
QuoteSessionTotalsService quoteSessionTotalsService,
|
||||||
QuoteSessionTotalsService quoteSessionTotalsService) {
|
QuoteSessionItemService quoteSessionItemService,
|
||||||
|
QuoteStorageService quoteStorageService,
|
||||||
|
QuoteSessionResponseAssembler quoteSessionResponseAssembler) {
|
||||||
this.sessionRepo = sessionRepo;
|
this.sessionRepo = sessionRepo;
|
||||||
this.lineItemRepo = lineItemRepo;
|
this.lineItemRepo = lineItemRepo;
|
||||||
this.slicerService = slicerService;
|
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
|
||||||
this.materialRepo = materialRepo;
|
|
||||||
this.variantRepo = variantRepo;
|
|
||||||
this.orcaProfileResolver = orcaProfileResolver;
|
|
||||||
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
|
|
||||||
this.pricingRepo = pricingRepo;
|
this.pricingRepo = pricingRepo;
|
||||||
this.clamAVService = clamAVService;
|
|
||||||
this.quoteSessionTotalsService = quoteSessionTotalsService;
|
this.quoteSessionTotalsService = quoteSessionTotalsService;
|
||||||
|
this.quoteSessionItemService = quoteSessionItemService;
|
||||||
|
this.quoteStorageService = quoteStorageService;
|
||||||
|
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Start a new empty session
|
|
||||||
@PostMapping(value = "")
|
@PostMapping(value = "")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<QuoteSession> createSession() {
|
public ResponseEntity<QuoteSession> createSession() {
|
||||||
QuoteSession session = new QuoteSession();
|
QuoteSession session = new QuoteSession();
|
||||||
session.setStatus("ACTIVE");
|
session.setStatus("ACTIVE");
|
||||||
session.setPricingVersion("v1");
|
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.setSupportsEnabled(false);
|
||||||
session.setCreatedAt(OffsetDateTime.now());
|
session.setCreatedAt(OffsetDateTime.now());
|
||||||
@@ -115,307 +75,143 @@ public class QuoteSessionController {
|
|||||||
return ResponseEntity.ok(session);
|
return ResponseEntity.ok(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add item to existing session
|
|
||||||
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
|
public ResponseEntity<QuoteLineItem> addItemToExistingSession(@PathVariable UUID id,
|
||||||
@PathVariable UUID id,
|
@RequestPart("settings") PrintSettingsDto settings,
|
||||||
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings,
|
@RequestPart("file") MultipartFile file) throws IOException {
|
||||||
@RequestPart("file") MultipartFile file
|
|
||||||
) throws IOException {
|
|
||||||
QuoteSession session = sessionRepo.findById(id)
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|
||||||
QuoteLineItem item = addItemToSession(session, file, settings);
|
QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings);
|
||||||
return ResponseEntity.ok(item);
|
return ResponseEntity.ok(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to add item
|
@PatchMapping("/line-items/{lineItemId}")
|
||||||
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
@Transactional
|
||||||
if (file.isEmpty()) throw new IllegalArgumentException("File is empty");
|
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())) {
|
if ("CONVERTED".equals(session.getStatus())) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
|
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan for virus
|
if (updates.containsKey("quantity")) {
|
||||||
clamAVService.scan(file.getInputStream());
|
item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
|
||||||
|
|
||||||
// 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");
|
|
||||||
}
|
}
|
||||||
Files.createDirectories(sessionStorageDir);
|
if (updates.containsKey("color_code")) {
|
||||||
|
Object colorValue = updates.get("color_code");
|
||||||
String originalFilename = file.getOriginalFilename();
|
if (colorValue != null) {
|
||||||
String ext = getSafeExtension(originalFilename, "stl");
|
item.setColorCode(String.valueOf(colorValue));
|
||||||
String storedFilename = UUID.randomUUID() + "." + ext;
|
|
||||||
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize();
|
|
||||||
if (!persistentPath.startsWith(sessionStorageDir)) {
|
|
||||||
throw new IOException("Invalid quote line-item storage path");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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());
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
return ResponseEntity.ok(lineItemRepo.save(item));
|
||||||
return lineItemRepo.save(item);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Cleanup if failed
|
|
||||||
Files.deleteIfExists(persistentPath);
|
|
||||||
if (convertedPersistentPath != null) {
|
|
||||||
Files.deleteIfExists(convertedPersistentPath);
|
|
||||||
}
|
}
|
||||||
throw e;
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemRepo.delete(item);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
String targetStoredPath = item.getStoredPath();
|
||||||
|
if (preview) {
|
||||||
|
String convertedPath = quoteStorageService.extractConvertedStoredPath(item);
|
||||||
|
if (convertedPath != null && !convertedPath.isBlank()) {
|
||||||
|
targetStoredPath = convertedPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
if (targetStoredPath == null) {
|
||||||
if (settings.getNozzleDiameter() == null) {
|
return ResponseEntity.notFound().build();
|
||||||
settings.setNozzleDiameter(0.40);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||||
// Set defaults based on Quality
|
if (path == null || !java.nio.file.Files.exists(path)) {
|
||||||
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
|
return ResponseEntity.notFound().build();
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.PrintSettingsDto settings) {
|
Resource resource = new UrlResource(path.toUri());
|
||||||
settings.setComplexityMode("ADVANCED");
|
String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename();
|
||||||
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
|
|
||||||
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
|
return ResponseEntity.ok()
|
||||||
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
|
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
|
||||||
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
|
.body(resource);
|
||||||
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private PrinterMachine resolvePrinterMachine(Long printerMachineId) {
|
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
|
||||||
if (printerMachineId != null) {
|
public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
|
||||||
PrinterMachine selected = machineRepo.findById(printerMachineId)
|
@PathVariable UUID lineItemId)
|
||||||
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId));
|
throws IOException {
|
||||||
if (!Boolean.TRUE.equals(selected.getIsActive())) {
|
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||||
throw new RuntimeException("Selected printer machine is not active");
|
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||||
}
|
|
||||||
return selected;
|
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return machineRepo.findFirstByIsActiveTrue()
|
if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
|
||||||
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) {
|
String targetStoredPath = item.getStoredPath();
|
||||||
if (settings.getFilamentVariantId() != null) {
|
if (targetStoredPath == null || targetStoredPath.isBlank()) {
|
||||||
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
|
return ResponseEntity.notFound().build();
|
||||||
.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());
|
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
|
||||||
|
if (path == null || !java.nio.file.Files.exists(path)) {
|
||||||
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode)
|
return ResponseEntity.notFound().build();
|
||||||
.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)
|
if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) {
|
||||||
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeRequestedMaterialCode(String value) {
|
Resource resource = new UrlResource(path.toUri());
|
||||||
if (value == null || value.isBlank()) {
|
String downloadName = path.getFileName().toString();
|
||||||
return "PLA";
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.trim()
|
return ResponseEntity.ok()
|
||||||
.toUpperCase(Locale.ROOT)
|
.contentType(MediaType.parseMediaType("model/stl"))
|
||||||
.replace('_', ' ')
|
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
|
||||||
.replace('-', ' ')
|
.body(resource);
|
||||||
.replaceAll("\\s+", " ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int parsePositiveQuantity(Object raw) {
|
private int parsePositiveQuantity(Object raw) {
|
||||||
@@ -443,262 +239,4 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
return quantity;
|
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;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class PrintSettingsDto {
|
public class PrintSettingsDto {
|
||||||
// Mode: "BASIC" or "ADVANCED"
|
// Mode: "BASIC" or "ADVANCED"
|
||||||
private String complexityMode;
|
private String complexityMode;
|
||||||
@@ -28,4 +25,124 @@ public class PrintSettingsDto {
|
|||||||
private Double boundingBoxX;
|
private Double boundingBoxX;
|
||||||
private Double boundingBoxY;
|
private Double boundingBoxY;
|
||||||
private Double boundingBoxZ;
|
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)
|
@Column(name = "quality", length = Integer.MAX_VALUE)
|
||||||
private String quality;
|
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)
|
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
|
||||||
private BigDecimal nozzleDiameterMm;
|
private BigDecimal nozzleDiameterMm;
|
||||||
|
|
||||||
@Column(name = "layer_height_mm", precision = 6, scale = 3)
|
@Column(name = "layer_height_mm", precision = 6, scale = 3)
|
||||||
private BigDecimal layerHeightMm;
|
private BigDecimal layerHeightMm;
|
||||||
|
|
||||||
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
|
|
||||||
private String infillPattern;
|
|
||||||
|
|
||||||
@Column(name = "infill_percent")
|
@Column(name = "infill_percent")
|
||||||
private Integer infillPercent;
|
private Integer infillPercent;
|
||||||
|
|
||||||
|
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
|
||||||
|
private String infillPattern;
|
||||||
|
|
||||||
@Column(name = "supports_enabled")
|
@Column(name = "supports_enabled")
|
||||||
private Boolean supportsEnabled;
|
private Boolean supportsEnabled;
|
||||||
|
|
||||||
@@ -232,54 +214,6 @@ public class QuoteLineItem {
|
|||||||
this.supportsEnabled = supportsEnabled;
|
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() {
|
public BigDecimal getBoundingBoxXMm() {
|
||||||
return boundingBoxXMm;
|
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