diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 908ef71..47d7d40 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -2,519 +2,46 @@ package com.printcalculator.controller; import com.printcalculator.dto.QuoteRequestDto; import com.printcalculator.entity.CustomQuoteRequest; -import com.printcalculator.entity.CustomQuoteRequestAttachment; -import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; -import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.service.storage.ClamAVService; -import com.printcalculator.service.email.EmailNotificationService; +import com.printcalculator.service.request.CustomQuoteRequestControllerService; import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; 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.server.ResponseStatusException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; -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.Year; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.regex.Pattern; @RestController @RequestMapping("/api/custom-quote-requests") public class CustomQuoteRequestController { - private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class); - private final CustomQuoteRequestRepository requestRepo; - private final CustomQuoteRequestAttachmentRepository attachmentRepo; - private final ClamAVService clamAVService; - private final EmailNotificationService emailNotificationService; + private final CustomQuoteRequestControllerService customQuoteRequestControllerService; - @Value("${app.mail.contact-request.admin.enabled:true}") - private boolean contactRequestAdminMailEnabled; - - @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") - private String contactRequestAdminMailAddress; - - @Value("${app.mail.contact-request.customer.enabled:true}") - private boolean contactRequestCustomerMailEnabled; - - // TODO: Inject Storage Service - private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); - private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); - private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( - "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" - ); - private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( - "application/zip", - "application/x-zip-compressed", - "application/x-rar-compressed", - "application/vnd.rar", - "application/x-7z-compressed", - "application/gzip", - "application/x-gzip", - "application/x-tar", - "application/x-bzip2", - "application/x-xz", - "application/zstd", - "application/x-zstd" - ); - - public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, - CustomQuoteRequestAttachmentRepository attachmentRepo, - ClamAVService clamAVService, - EmailNotificationService emailNotificationService) { - this.requestRepo = requestRepo; - this.attachmentRepo = attachmentRepo; - this.clamAVService = clamAVService; - this.emailNotificationService = emailNotificationService; + public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) { + this.customQuoteRequestControllerService = customQuoteRequestControllerService; } - // 1. Create Custom Quote Request @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity createCustomQuoteRequest( @Valid @RequestPart("request") QuoteRequestDto requestDto, @RequestPart(value = "files", required = false) List files ) throws IOException { - if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Accettazione Termini e Privacy obbligatoria." - ); - } - String language = normalizeLanguage(requestDto.getLanguage()); - - // 1. Create Request - CustomQuoteRequest request = new CustomQuoteRequest(); - request.setRequestType(requestDto.getRequestType()); - request.setCustomerType(requestDto.getCustomerType()); - request.setEmail(requestDto.getEmail()); - request.setPhone(requestDto.getPhone()); - request.setName(requestDto.getName()); - request.setCompanyName(requestDto.getCompanyName()); - request.setContactPerson(requestDto.getContactPerson()); - request.setMessage(requestDto.getMessage()); - request.setStatus("PENDING"); - request.setCreatedAt(OffsetDateTime.now()); - request.setUpdatedAt(OffsetDateTime.now()); - - request = requestRepo.save(request); - - // 2. Handle Attachments - int attachmentsCount = 0; - if (files != null && !files.isEmpty()) { - if (files.size() > 15) { - throw new IOException("Too many files. Max 15 allowed."); - } - - for (MultipartFile file : files) { - if (file.isEmpty()) continue; - - if (isCompressedFile(file)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Compressed files are not allowed." - ); - } - - // Scan for virus - clamAVService.scan(file.getInputStream()); - - CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); - attachment.setRequest(request); - attachment.setOriginalFilename(file.getOriginalFilename()); - attachment.setMimeType(file.getContentType()); - attachment.setFileSizeBytes(file.getSize()); - attachment.setCreatedAt(OffsetDateTime.now()); - - // Generate path - UUID fileUuid = UUID.randomUUID(); - String storedFilename = fileUuid + ".upload"; - - // Note: We don't have attachment ID yet. - // We'll save attachment first to get ID. - attachment.setStoredFilename(storedFilename); - attachment.setStoredRelativePath("PENDING"); - - attachment = attachmentRepo.save(attachment); - - Path relativePath = Path.of( - "quote-requests", - request.getId().toString(), - "attachments", - attachment.getId().toString(), - storedFilename - ); - attachment.setStoredRelativePath(relativePath.toString()); - attachmentRepo.save(attachment); - - // Save file to disk - Path absolutePath = resolveWithinStorageRoot(relativePath); - Files.createDirectories(absolutePath.getParent()); - try (InputStream inputStream = file.getInputStream()) { - Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); - } - attachmentsCount++; - } - } - - sendAdminContactRequestNotification(request, attachmentsCount); - sendCustomerContactRequestConfirmation(request, attachmentsCount, language); - - return ResponseEntity.ok(request); + return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files)); } - - // 2. Get Request + @GetMapping("/{id}") public ResponseEntity getCustomQuoteRequest(@PathVariable UUID id) { - return requestRepo.findById(id) + return customQuoteRequestControllerService.getCustomQuoteRequest(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } - - // Helper - private String getExtension(String filename) { - if (filename == null) return "dat"; - String cleaned = StringUtils.cleanPath(filename); - if (cleaned.contains("..")) { - return "dat"; - } - int i = cleaned.lastIndexOf('.'); - if (i > 0 && i < cleaned.length() - 1) { - String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); - if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { - return ext; - } - } - return "dat"; - } - - private boolean isCompressedFile(MultipartFile file) { - String ext = getExtension(file.getOriginalFilename()); - if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { - return true; - } - String mime = file.getContentType(); - return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase()); - } - - private Path resolveWithinStorageRoot(Path relativePath) { - try { - Path normalizedRelative = relativePath.normalize(); - if (normalizedRelative.isAbsolute()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); - if (!absolutePath.startsWith(STORAGE_ROOT)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - return absolutePath; - } catch (InvalidPathException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - } - - private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) { - if (!contactRequestAdminMailEnabled) { - return; - } - if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) { - logger.warn("Contact request admin notification enabled but no admin address configured."); - return; - } - - Map templateData = new HashMap<>(); - templateData.put("requestId", request.getId()); - templateData.put("createdAt", request.getCreatedAt()); - templateData.put("requestType", safeValue(request.getRequestType())); - templateData.put("customerType", safeValue(request.getCustomerType())); - templateData.put("name", safeValue(request.getName())); - templateData.put("companyName", safeValue(request.getCompanyName())); - templateData.put("contactPerson", safeValue(request.getContactPerson())); - templateData.put("email", safeValue(request.getEmail())); - templateData.put("phone", safeValue(request.getPhone())); - templateData.put("message", safeValue(request.getMessage())); - templateData.put("attachmentsCount", attachmentsCount); - templateData.put("currentYear", Year.now().getValue()); - - emailNotificationService.sendEmail( - contactRequestAdminMailAddress, - "Nuova richiesta di contatto #" + request.getId(), - "contact-request-admin", - templateData - ); - } - - private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) { - if (!contactRequestCustomerMailEnabled) { - return; - } - if (request.getEmail() == null || request.getEmail().isBlank()) { - logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId()); - return; - } - - Map templateData = new HashMap<>(); - templateData.put("requestId", request.getId()); - templateData.put( - "createdAt", - request.getCreatedAt().format( - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language)) - ) - ); - templateData.put("recipientName", resolveRecipientName(request, language)); - templateData.put("requestType", localizeRequestType(request.getRequestType(), language)); - templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language)); - templateData.put("name", safeValue(request.getName())); - templateData.put("companyName", safeValue(request.getCompanyName())); - templateData.put("contactPerson", safeValue(request.getContactPerson())); - templateData.put("email", safeValue(request.getEmail())); - templateData.put("phone", safeValue(request.getPhone())); - templateData.put("message", safeValue(request.getMessage())); - templateData.put("attachmentsCount", attachmentsCount); - templateData.put("currentYear", Year.now().getValue()); - String subject = applyCustomerContactRequestTexts(templateData, language, request.getId()); - - emailNotificationService.sendEmail( - request.getEmail(), - subject, - "contact-request-customer", - templateData - ); - } - - private String applyCustomerContactRequestTexts( - Map templateData, - String language, - UUID requestId - ) { - return switch (language) { - case "en" -> { - templateData.put("emailTitle", "Contact request received"); - templateData.put("headlineText", "We received your contact request"); - templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ","); - templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible."); - templateData.put("requestIdHintText", "Please keep this request ID for future order references:"); - templateData.put("detailsTitleText", "Request details"); - templateData.put("labelRequestId", "Request ID"); - templateData.put("labelDate", "Date"); - templateData.put("labelRequestType", "Request type"); - templateData.put("labelCustomerType", "Customer type"); - templateData.put("labelName", "Name"); - templateData.put("labelCompany", "Company"); - templateData.put("labelContactPerson", "Contact person"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Phone"); - templateData.put("labelMessage", "Message"); - templateData.put("labelAttachments", "Attachments"); - templateData.put("supportText", "If you need help, reply to this email."); - templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab."); - yield "We received your contact request #" + requestId + " - 3D-Fab"; - } - case "de" -> { - templateData.put("emailTitle", "Kontaktanfrage erhalten"); - templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten"); - templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ","); - templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich."); - templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:"); - templateData.put("detailsTitleText", "Anfragedetails"); - templateData.put("labelRequestId", "Anfrage-ID"); - templateData.put("labelDate", "Datum"); - templateData.put("labelRequestType", "Anfragetyp"); - templateData.put("labelCustomerType", "Kundentyp"); - templateData.put("labelName", "Name"); - templateData.put("labelCompany", "Firma"); - templateData.put("labelContactPerson", "Kontaktperson"); - templateData.put("labelEmail", "E-Mail"); - templateData.put("labelPhone", "Telefon"); - templateData.put("labelMessage", "Nachricht"); - templateData.put("labelAttachments", "Anhaenge"); - templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail."); - templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab."); - yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab"; - } - case "fr" -> { - templateData.put("emailTitle", "Demande de contact recue"); - templateData.put("headlineText", "Nous avons recu votre demande de contact"); - templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ","); - templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible."); - templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :"); - templateData.put("detailsTitleText", "Details de la demande"); - templateData.put("labelRequestId", "ID de demande"); - templateData.put("labelDate", "Date"); - templateData.put("labelRequestType", "Type de demande"); - templateData.put("labelCustomerType", "Type de client"); - templateData.put("labelName", "Nom"); - templateData.put("labelCompany", "Entreprise"); - templateData.put("labelContactPerson", "Contact"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Telephone"); - templateData.put("labelMessage", "Message"); - templateData.put("labelAttachments", "Pieces jointes"); - templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); - templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab."); - yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab"; - } - default -> { - templateData.put("emailTitle", "Richiesta di contatto ricevuta"); - templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto"); - templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ","); - templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile."); - templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:"); - templateData.put("detailsTitleText", "Dettagli richiesta"); - templateData.put("labelRequestId", "ID richiesta"); - templateData.put("labelDate", "Data"); - templateData.put("labelRequestType", "Tipo richiesta"); - templateData.put("labelCustomerType", "Tipo cliente"); - templateData.put("labelName", "Nome"); - templateData.put("labelCompany", "Azienda"); - templateData.put("labelContactPerson", "Contatto"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Telefono"); - templateData.put("labelMessage", "Messaggio"); - templateData.put("labelAttachments", "Allegati"); - templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email."); - templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab."); - yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab"; - } - }; - } - - private String localizeRequestType(String requestType, String language) { - if (requestType == null || requestType.isBlank()) { - return "-"; - } - - String normalized = requestType.trim().toLowerCase(Locale.ROOT); - return switch (language) { - case "en" -> switch (normalized) { - case "custom", "print_service" -> "Custom part request"; - case "series" -> "Series production request"; - case "consult", "design_service" -> "Consultation request"; - case "question" -> "General question"; - default -> requestType; - }; - case "de" -> switch (normalized) { - case "custom", "print_service" -> "Anfrage fuer Einzelteil"; - case "series" -> "Anfrage fuer Serienproduktion"; - case "consult", "design_service" -> "Beratungsanfrage"; - case "question" -> "Allgemeine Frage"; - default -> requestType; - }; - case "fr" -> switch (normalized) { - case "custom", "print_service" -> "Demande de piece personnalisee"; - case "series" -> "Demande de production en serie"; - case "consult", "design_service" -> "Demande de conseil"; - case "question" -> "Question generale"; - default -> requestType; - }; - default -> switch (normalized) { - case "custom", "print_service" -> "Richiesta pezzo personalizzato"; - case "series" -> "Richiesta produzione in serie"; - case "consult", "design_service" -> "Richiesta consulenza"; - case "question" -> "Domanda generale"; - default -> requestType; - }; - }; - } - - private String localizeCustomerType(String customerType, String language) { - if (customerType == null || customerType.isBlank()) { - return "-"; - } - String normalized = customerType.trim().toLowerCase(Locale.ROOT); - return switch (language) { - case "en" -> switch (normalized) { - case "private" -> "Private"; - case "business" -> "Business"; - default -> customerType; - }; - case "de" -> switch (normalized) { - case "private" -> "Privat"; - case "business" -> "Unternehmen"; - default -> customerType; - }; - case "fr" -> switch (normalized) { - case "private" -> "Prive"; - case "business" -> "Entreprise"; - default -> customerType; - }; - default -> switch (normalized) { - case "private" -> "Privato"; - case "business" -> "Azienda"; - default -> customerType; - }; - }; - } - - private Locale localeForLanguage(String language) { - return switch (language) { - case "en" -> Locale.ENGLISH; - case "de" -> Locale.GERMAN; - case "fr" -> Locale.FRENCH; - default -> Locale.ITALIAN; - }; - } - - private String normalizeLanguage(String language) { - if (language == null || language.isBlank()) { - return "it"; - } - String normalized = language.toLowerCase(Locale.ROOT).trim(); - if (normalized.startsWith("en")) { - return "en"; - } - if (normalized.startsWith("de")) { - return "de"; - } - if (normalized.startsWith("fr")) { - return "fr"; - } - return "it"; - } - - private String resolveRecipientName(CustomQuoteRequest request, String language) { - if (request.getName() != null && !request.getName().isBlank()) { - return request.getName().trim(); - } - if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { - return request.getContactPerson().trim(); - } - if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) { - return request.getCompanyName().trim(); - } - return switch (language) { - case "en" -> "customer"; - case "de" -> "Kunde"; - case "fr" -> "client"; - default -> "cliente"; - }; - } - - private String safeValue(String value) { - if (value == null || value.isBlank()) { - return "-"; - } - return value; - } } diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 56cd26b..28a1abb 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -12,8 +12,10 @@ import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.repository.PrinterMachineProfileRepository; import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.ProfileManager; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; @@ -22,8 +24,11 @@ import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.Comparator; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.LinkedHashSet; import java.util.Set; import java.util.stream.Collectors; @@ -34,23 +39,29 @@ public class OptionsController { private final FilamentVariantRepository variantRepo; private final NozzleOptionRepository nozzleRepo; private final PrinterMachineRepository printerMachineRepo; + private final PrinterMachineProfileRepository printerMachineProfileRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final OrcaProfileResolver orcaProfileResolver; + private final ProfileManager profileManager; private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, NozzleOptionRepository nozzleRepo, PrinterMachineRepository printerMachineRepo, + PrinterMachineProfileRepository printerMachineProfileRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo, OrcaProfileResolver orcaProfileResolver, + ProfileManager profileManager, NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.nozzleRepo = nozzleRepo; this.printerMachineRepo = printerMachineRepo; + this.printerMachineProfileRepo = printerMachineProfileRepo; this.materialOrcaMapRepo = materialOrcaMapRepo; this.orcaProfileResolver = orcaProfileResolver; + this.profileManager = profileManager; this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @@ -116,8 +127,27 @@ public class OptionsController { new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); + PrinterMachine targetMachine = resolveMachine(printerMachineId); + + Set supportedMachineNozzles = targetMachine != null + ? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream() + .map(PrinterMachineProfile::getNozzleDiameterMm) + .filter(v -> v != null) + .map(nozzleLayerHeightPolicyService::normalizeNozzle) + .collect(Collectors.toCollection(LinkedHashSet::new)) + : Set.of(); + + boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty(); + List nozzles = nozzleRepo.findAll().stream() .filter(n -> Boolean.TRUE.equals(n.getIsActive())) + .filter(n -> { + if (!restrictNozzlesByMachineProfile) { + return true; + } + BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(n.getNozzleDiameterMm()); + return normalized != null && supportedMachineNozzles.contains(normalized); + }) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .map(n -> new OptionsResponse.NozzleOptionDTO( n.getNozzleDiameterMm().doubleValue(), @@ -129,16 +159,62 @@ public class OptionsController { .toList(); Map> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); + Set visibleNozzlesFromOptions = nozzles.stream() + .map(OptionsResponse.NozzleOptionDTO::value) + .map(BigDecimal::valueOf) + .map(nozzleLayerHeightPolicyService::normalizeNozzle) + .filter(v -> v != null) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + Map> effectiveRulesByNozzle = new LinkedHashMap<>(); + for (BigDecimal nozzle : visibleNozzlesFromOptions) { + List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of()); + List compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle); + List effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers); + if (!effective.isEmpty()) { + effectiveRulesByNozzle.put(nozzle, effective); + } + } + if (effectiveRulesByNozzle.isEmpty()) { + for (BigDecimal nozzle : visibleNozzlesFromOptions) { + List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of()); + if (!policyLayers.isEmpty()) { + effectiveRulesByNozzle.put(nozzle, policyLayers); + } + } + } + + Set visibleNozzles = new LinkedHashSet<>(effectiveRulesByNozzle.keySet()); + nozzles = nozzles.stream() + .filter(option -> { + BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle( + BigDecimal.valueOf(option.value()) + ); + return normalized != null && visibleNozzles.contains(normalized); + }) + .toList(); + BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null ); - - List layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of())); - if (layers.isEmpty()) { - layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) { + selectedNozzle = visibleNozzles.iterator().next(); } - List layerHeightsByNozzle = rulesByNozzle.entrySet().stream() + List layers = toLayerDtos( + effectiveRulesByNozzle.getOrDefault(selectedNozzle, List.of()) + ); + if (layers.isEmpty()) { + if (!visibleNozzles.isEmpty()) { + BigDecimal fallbackNozzle = visibleNozzles.iterator().next(); + layers = toLayerDtos(effectiveRulesByNozzle.getOrDefault(fallbackNozzle, List.of())); + } + if (layers.isEmpty()) { + layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + } + } + + List layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream() .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO( entry.getKey().doubleValue(), toLayerDtos(entry.getValue()) @@ -156,13 +232,7 @@ public class OptionsController { } private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { - PrinterMachine machine = null; - if (printerMachineId != null) { - machine = printerMachineRepo.findById(printerMachineId).orElse(null); - } - if (machine == null) { - machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); - } + PrinterMachine machine = resolveMachine(printerMachineId); if (machine == null) { return Set.of(); } @@ -187,6 +257,17 @@ public class OptionsController { .collect(Collectors.toSet()); } + private PrinterMachine resolveMachine(Long printerMachineId) { + PrinterMachine machine = null; + if (printerMachineId != null) { + machine = printerMachineRepo.findById(printerMachineId).orElse(null); + } + if (machine == null) { + machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); + } + return machine; + } + private List toLayerDtos(List layers) { return layers.stream() .sorted(Comparator.naturalOrder()) @@ -197,6 +278,52 @@ public class OptionsController { .toList(); } + private List resolveCompatibleProcessLayers(PrinterMachine machine, BigDecimal nozzle) { + if (machine == null || nozzle == null) { + return List.of(); + } + PrinterMachineProfile profile = orcaProfileResolver.resolveMachineProfile(machine, nozzle).orElse(null); + if (profile == null || profile.getOrcaMachineProfileName() == null) { + return List.of(); + } + return profileManager.findCompatibleProcessLayers(profile.getOrcaMachineProfileName()); + } + + private List mergePolicyAndProcessLayers(List policyLayers, + List processLayers) { + if ((processLayers == null || processLayers.isEmpty()) + && (policyLayers == null || policyLayers.isEmpty())) { + return List.of(); + } + + if (processLayers == null || processLayers.isEmpty()) { + return policyLayers != null ? policyLayers : List.of(); + } + + if (policyLayers == null || policyLayers.isEmpty()) { + return processLayers; + } + + Set allowedByPolicy = policyLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + List intersection = processLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null && allowedByPolicy.contains(v)) + .collect(Collectors.toCollection(ArrayList::new)); + + if (!intersection.isEmpty()) { + return intersection; + } + + return processLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null) + .collect(Collectors.toCollection(ArrayList::new)); + } + private String resolveHexColor(FilamentVariant variant) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { return variant.getColorHex(); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java index 2d469e6..b6da0b8 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java @@ -4,77 +4,39 @@ import com.printcalculator.dto.AdminFilamentMaterialTypeDto; import com.printcalculator.dto.AdminFilamentVariantDto; import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; -import com.printcalculator.entity.FilamentMaterialType; -import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.repository.FilamentMaterialTypeRepository; -import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.admin.AdminFilamentControllerService; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.util.Comparator; import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/admin/filaments") @Transactional(readOnly = true) public class AdminFilamentController { - private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); - private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); - private static final Set ALLOWED_FINISH_TYPES = Set.of( - "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" - ); - private final FilamentMaterialTypeRepository materialRepo; - private final FilamentVariantRepository variantRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final OrderItemRepository orderItemRepo; + private final AdminFilamentControllerService adminFilamentControllerService; - public AdminFilamentController( - FilamentMaterialTypeRepository materialRepo, - FilamentVariantRepository variantRepo, - QuoteLineItemRepository quoteLineItemRepo, - OrderItemRepository orderItemRepo - ) { - this.materialRepo = materialRepo; - this.variantRepo = variantRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.orderItemRepo = orderItemRepo; + public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) { + this.adminFilamentControllerService = adminFilamentControllerService; } @GetMapping("/materials") public ResponseEntity> getMaterials() { - List response = materialRepo.findAll().stream() - .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) - .map(this::toMaterialDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminFilamentControllerService.getMaterials()); } @GetMapping("/variants") public ResponseEntity> getVariants() { - List response = variantRepo.findAll().stream() - .sorted(Comparator - .comparing((FilamentVariant v) -> { - FilamentMaterialType type = v.getFilamentMaterialType(); - return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; - }, String.CASE_INSENSITIVE_ORDER) - .thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) - .map(this::toVariantDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminFilamentControllerService.getVariants()); } @PostMapping("/materials") @@ -82,13 +44,7 @@ public class AdminFilamentController { public ResponseEntity createMaterial( @RequestBody AdminUpsertFilamentMaterialTypeRequest payload ) { - String materialCode = normalizeAndValidateMaterialCode(payload); - ensureMaterialCodeAvailable(materialCode, null); - - FilamentMaterialType material = new FilamentMaterialType(); - applyMaterialPayload(material, payload, materialCode); - FilamentMaterialType saved = materialRepo.save(material); - return ResponseEntity.ok(toMaterialDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload)); } @PutMapping("/materials/{materialTypeId}") @@ -97,15 +53,7 @@ public class AdminFilamentController { @PathVariable Long materialTypeId, @RequestBody AdminUpsertFilamentMaterialTypeRequest payload ) { - FilamentMaterialType material = materialRepo.findById(materialTypeId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); - - String materialCode = normalizeAndValidateMaterialCode(payload); - ensureMaterialCodeAvailable(materialCode, materialTypeId); - - applyMaterialPayload(material, payload, materialCode); - FilamentMaterialType saved = materialRepo.save(material); - return ResponseEntity.ok(toMaterialDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload)); } @PostMapping("/variants") @@ -113,17 +61,7 @@ public class AdminFilamentController { public ResponseEntity createVariant( @RequestBody AdminUpsertFilamentVariantRequest payload ) { - FilamentMaterialType material = validateAndResolveMaterial(payload); - String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); - String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); - validateNumericPayload(payload); - ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); - - FilamentVariant variant = new FilamentVariant(); - variant.setCreatedAt(OffsetDateTime.now()); - applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); - FilamentVariant saved = variantRepo.save(variant); - return ResponseEntity.ok(toVariantDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.createVariant(payload)); } @PutMapping("/variants/{variantId}") @@ -132,224 +70,13 @@ public class AdminFilamentController { @PathVariable Long variantId, @RequestBody AdminUpsertFilamentVariantRequest payload ) { - FilamentVariant variant = variantRepo.findById(variantId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); - - FilamentMaterialType material = validateAndResolveMaterial(payload); - String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); - String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); - validateNumericPayload(payload); - ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); - - applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); - FilamentVariant saved = variantRepo.save(variant); - return ResponseEntity.ok(toVariantDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload)); } @DeleteMapping("/variants/{variantId}") @Transactional public ResponseEntity deleteVariant(@PathVariable Long variantId) { - FilamentVariant variant = variantRepo.findById(variantId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); - - if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { - throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); - } - - variantRepo.delete(variant); + adminFilamentControllerService.deleteVariant(variantId); return ResponseEntity.noContent().build(); } - - private void applyMaterialPayload( - FilamentMaterialType material, - AdminUpsertFilamentMaterialTypeRequest payload, - String normalizedMaterialCode - ) { - boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); - boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); - String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null - ? payload.getTechnicalTypeLabel().trim() - : null; - - material.setMaterialCode(normalizedMaterialCode); - material.setIsFlexible(isFlexible); - material.setIsTechnical(isTechnical); - material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() - ? technicalTypeLabel - : null); - } - - private void applyVariantPayload( - FilamentVariant variant, - AdminUpsertFilamentVariantRequest payload, - FilamentMaterialType material, - String normalizedDisplayName, - String normalizedColorName - ) { - String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); - String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); - String normalizedBrand = normalizeOptional(payload.getBrand()); - - variant.setFilamentMaterialType(material); - variant.setVariantDisplayName(normalizedDisplayName); - variant.setColorName(normalizedColorName); - variant.setColorHex(normalizedColorHex); - variant.setFinishType(normalizedFinishType); - variant.setBrand(normalizedBrand); - variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); - variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); - variant.setCostChfPerKg(payload.getCostChfPerKg()); - variant.setStockSpools(payload.getStockSpools()); - variant.setSpoolNetKg(payload.getSpoolNetKg()); - variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); - } - - private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { - if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); - } - return payload.getMaterialCode().trim().toUpperCase(); - } - - private String normalizeAndValidateVariantDisplayName(String value) { - if (value == null || value.isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); - } - return value.trim(); - } - - private String normalizeAndValidateColorName(String value) { - if (value == null || value.isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); - } - return value.trim(); - } - - private String normalizeAndValidateColorHex(String value) { - if (value == null || value.isBlank()) { - return null; - } - String normalized = value.trim(); - if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { - throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); - } - return normalized.toUpperCase(Locale.ROOT); - } - - private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { - String normalized = finishType == null || finishType.isBlank() - ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") - : finishType.trim().toUpperCase(Locale.ROOT); - if (!ALLOWED_FINISH_TYPES.contains(normalized)) { - throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); - } - return normalized; - } - - private String normalizeOptional(String value) { - if (value == null) { - return null; - } - String normalized = value.trim(); - return normalized.isBlank() ? null : normalized; - } - - private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { - if (payload == null || payload.getMaterialTypeId() == null) { - throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); - } - - return materialRepo.findById(payload.getMaterialTypeId()) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); - } - - private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { - if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { - throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); - } - validateNumeric63(payload.getStockSpools(), "Stock spools", true); - validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); - } - - private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { - if (value == null) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); - } - - if (allowZero) { - if (value.compareTo(BigDecimal.ZERO) < 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); - } - } else if (value.compareTo(BigDecimal.ZERO) <= 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); - } - - if (value.scale() > 3) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); - } - - if (value.compareTo(MAX_NUMERIC_6_3) > 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); - } - } - - private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { - materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { - if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { - throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); - } - }); - } - - private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { - variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { - if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { - throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); - } - }); - } - - private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { - AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); - dto.setId(material.getId()); - dto.setMaterialCode(material.getMaterialCode()); - dto.setIsFlexible(material.getIsFlexible()); - dto.setIsTechnical(material.getIsTechnical()); - dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); - return dto; - } - - private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { - AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); - dto.setId(variant.getId()); - - FilamentMaterialType material = variant.getFilamentMaterialType(); - if (material != null) { - dto.setMaterialTypeId(material.getId()); - dto.setMaterialCode(material.getMaterialCode()); - dto.setMaterialIsFlexible(material.getIsFlexible()); - dto.setMaterialIsTechnical(material.getIsTechnical()); - dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); - } - - dto.setVariantDisplayName(variant.getVariantDisplayName()); - dto.setColorName(variant.getColorName()); - dto.setColorHex(variant.getColorHex()); - dto.setFinishType(variant.getFinishType()); - dto.setBrand(variant.getBrand()); - dto.setIsMatte(variant.getIsMatte()); - dto.setIsSpecial(variant.getIsSpecial()); - dto.setCostChfPerKg(variant.getCostChfPerKg()); - dto.setStockSpools(variant.getStockSpools()); - dto.setSpoolNetKg(variant.getSpoolNetKg()); - BigDecimal stockKg = BigDecimal.ZERO; - if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { - stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); - } - dto.setStockKg(stockKg); - dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); - dto.setIsActive(variant.getIsActive()); - dto.setCreatedAt(variant.getCreatedAt()); - return dto; - } } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java index 52b8149..19c4d0b 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -1,37 +1,14 @@ package com.printcalculator.controller.admin; -import com.printcalculator.dto.AdminContactRequestDto; -import com.printcalculator.dto.AdminContactRequestAttachmentDto; -import com.printcalculator.dto.AdminContactRequestDetailDto; import com.printcalculator.dto.AdminCadInvoiceCreateRequest; import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminContactRequestDto; import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; -import com.printcalculator.entity.CustomQuoteRequest; -import com.printcalculator.entity.CustomQuoteRequestAttachment; -import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.FilamentVariantStockKg; -import com.printcalculator.entity.Order; -import com.printcalculator.entity.QuoteLineItem; -import com.printcalculator.entity.QuoteSession; -import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; -import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.FilamentVariantStockKgRepository; -import com.printcalculator.repository.OrderRepository; -import com.printcalculator.repository.PricingPolicyRepository; -import com.printcalculator.repository.QuoteLineItemRepository; -import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.service.QuoteSessionTotalsService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.printcalculator.service.admin.AdminOperationsControllerService; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; -import org.springframework.data.domain.Sort; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; @@ -42,148 +19,34 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.net.MalformedURLException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.OffsetDateTime; -import java.util.Collections; -import java.util.Comparator; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; -import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/admin") @Transactional(readOnly = true) public class AdminOperationsController { - private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class); - private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); - private static final Set CONTACT_REQUEST_ALLOWED_STATUSES = Set.of( - "NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED" - ); - private final FilamentVariantStockKgRepository filamentStockRepo; - private final FilamentVariantRepository filamentVariantRepo; - private final CustomQuoteRequestRepository customQuoteRequestRepo; - private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; - private final QuoteSessionRepository quoteSessionRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final OrderRepository orderRepo; - private final PricingPolicyRepository pricingRepo; - private final QuoteSessionTotalsService quoteSessionTotalsService; + private final AdminOperationsControllerService adminOperationsControllerService; - public AdminOperationsController( - FilamentVariantStockKgRepository filamentStockRepo, - FilamentVariantRepository filamentVariantRepo, - CustomQuoteRequestRepository customQuoteRequestRepo, - CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, - QuoteSessionRepository quoteSessionRepo, - QuoteLineItemRepository quoteLineItemRepo, - OrderRepository orderRepo, - PricingPolicyRepository pricingRepo, - QuoteSessionTotalsService quoteSessionTotalsService - ) { - this.filamentStockRepo = filamentStockRepo; - this.filamentVariantRepo = filamentVariantRepo; - this.customQuoteRequestRepo = customQuoteRequestRepo; - this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; - this.quoteSessionRepo = quoteSessionRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.orderRepo = orderRepo; - this.pricingRepo = pricingRepo; - this.quoteSessionTotalsService = quoteSessionTotalsService; + public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) { + this.adminOperationsControllerService = adminOperationsControllerService; } @GetMapping("/filament-stock") public ResponseEntity> getFilamentStock() { - List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); - Set variantIds = stocks.stream() - .map(FilamentVariantStockKg::getFilamentVariantId) - .collect(Collectors.toSet()); - - Map variantsById; - if (variantIds.isEmpty()) { - variantsById = Collections.emptyMap(); - } else { - variantsById = filamentVariantRepo.findAllById(variantIds).stream() - .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); - } - - List response = stocks.stream().map(stock -> { - FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); - AdminFilamentStockDto dto = new AdminFilamentStockDto(); - dto.setFilamentVariantId(stock.getFilamentVariantId()); - dto.setStockSpools(stock.getStockSpools()); - dto.setSpoolNetKg(stock.getSpoolNetKg()); - dto.setStockKg(stock.getStockKg()); - BigDecimal grams = stock.getStockKg() != null - ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) - : BigDecimal.ZERO; - dto.setStockFilamentGrams(grams); - - if (variant != null) { - dto.setMaterialCode( - variant.getFilamentMaterialType() != null - ? variant.getFilamentMaterialType().getMaterialCode() - : "UNKNOWN" - ); - dto.setVariantDisplayName(variant.getVariantDisplayName()); - dto.setColorName(variant.getColorName()); - dto.setActive(variant.getIsActive()); - } else { - dto.setMaterialCode("UNKNOWN"); - dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); - dto.setColorName("-"); - dto.setActive(false); - } - - return dto; - }).toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock()); } @GetMapping("/contact-requests") public ResponseEntity> getContactRequests() { - List response = customQuoteRequestRepo.findAll( - Sort.by(Sort.Direction.DESC, "createdAt") - ) - .stream() - .map(this::toContactRequestDto) - .toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getContactRequests()); } @GetMapping("/contact-requests/{requestId}") public ResponseEntity getContactRequestDetail(@PathVariable UUID requestId) { - CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); - - List attachments = customQuoteRequestAttachmentRepo - .findByRequest_IdOrderByCreatedAtAsc(requestId) - .stream() - .map(this::toContactRequestAttachmentDto) - .toList(); - - return ResponseEntity.ok(toContactRequestDetailDto(request, attachments)); + return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId)); } @PatchMapping("/contact-requests/{requestId}/status") @@ -192,31 +55,7 @@ public class AdminOperationsController { @PathVariable UUID requestId, @RequestBody AdminUpdateContactRequestStatusRequest payload ) { - CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); - - String requestedStatus = payload != null && payload.getStatus() != null - ? payload.getStatus().trim().toUpperCase(Locale.ROOT) - : ""; - - if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) { - throw new ResponseStatusException( - BAD_REQUEST, - "Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES) - ); - } - - request.setStatus(requestedStatus); - request.setUpdatedAt(OffsetDateTime.now()); - CustomQuoteRequest saved = customQuoteRequestRepo.save(request); - - List attachments = customQuoteRequestAttachmentRepo - .findByRequest_IdOrderByCreatedAtAsc(requestId) - .stream() - .map(this::toContactRequestAttachmentDto) - .toList(); - - return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments)); + return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload)); } @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") @@ -224,87 +63,17 @@ public class AdminOperationsController { @PathVariable UUID requestId, @PathVariable UUID attachmentId ) { - CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); - - if (!attachment.getRequest().getId().equals(requestId)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); - } - - String relativePath = attachment.getStoredRelativePath(); - if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; - if (!relativePath.startsWith(expectedPrefix)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); - if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - if (!Files.exists(filePath)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - try { - Resource resource = new UrlResource(filePath.toUri()); - if (!resource.exists() || !resource.isReadable()) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; - String mimeType = attachment.getMimeType(); - if (mimeType != null && !mimeType.isBlank()) { - try { - mediaType = MediaType.parseMediaType(mimeType); - } catch (Exception ignored) { - mediaType = MediaType.APPLICATION_OCTET_STREAM; - } - } - - String filename = attachment.getOriginalFilename(); - if (filename == null || filename.isBlank()) { - filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() - ? attachment.getStoredFilename() - : "attachment-" + attachmentId; - } - - return ResponseEntity.ok() - .contentType(mediaType) - .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build() - .toString()) - .body(resource); - } catch (MalformedURLException e) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } + return adminOperationsControllerService.downloadContactRequestAttachment(requestId, attachmentId); } @GetMapping("/sessions") public ResponseEntity> getQuoteSessions() { - List response = quoteSessionRepo.findAll( - Sort.by(Sort.Direction.DESC, "createdAt") - ) - .stream() - .map(this::toQuoteSessionDto) - .toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions()); } @GetMapping("/cad-invoices") public ResponseEntity> getCadInvoices() { - List response = quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) - .stream() - .filter(this::isCadSessionRecord) - .map(this::toCadInvoiceDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getCadInvoices()); } @PostMapping("/cad-invoices") @@ -312,198 +81,13 @@ public class AdminOperationsController { public ResponseEntity createOrUpdateCadInvoice( @RequestBody AdminCadInvoiceCreateRequest payload ) { - if (payload == null || payload.getCadHours() == null) { - throw new ResponseStatusException(BAD_REQUEST, "cadHours is required"); - } - - BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP); - if (cadHours.compareTo(BigDecimal.ZERO) <= 0) { - throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0"); - } - - BigDecimal cadRate = payload.getCadHourlyRateChf(); - if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) { - var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); - cadRate = policy != null && policy.getCadCostChfPerHour() != null - ? policy.getCadCostChfPerHour() - : BigDecimal.ZERO; - } - cadRate = cadRate.setScale(2, RoundingMode.HALF_UP); - - QuoteSession session; - if (payload.getSessionId() != null) { - session = quoteSessionRepo.findById(payload.getSessionId()) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); - } else { - session = new QuoteSession(); - session.setStatus("CAD_ACTIVE"); - session.setPricingVersion("v1"); - session.setMaterialCode("PLA"); - session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); - session.setLayerHeightMm(BigDecimal.valueOf(0.2)); - session.setInfillPattern("grid"); - session.setInfillPercent(20); - session.setSupportsEnabled(false); - session.setSetupCostChf(BigDecimal.ZERO); - session.setCreatedAt(OffsetDateTime.now()); - session.setExpiresAt(OffsetDateTime.now().plusDays(30)); - } - - if ("CONVERTED".equals(session.getStatus())) { - throw new ResponseStatusException(CONFLICT, "Session already converted to order"); - } - - if (payload.getSourceRequestId() != null) { - if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) { - throw new ResponseStatusException(NOT_FOUND, "Source request not found"); - } - session.setSourceRequestId(payload.getSourceRequestId()); - } else { - session.setSourceRequestId(null); - } - - session.setStatus("CAD_ACTIVE"); - session.setCadHours(cadHours); - session.setCadHourlyRateChf(cadRate); - if (payload.getNotes() != null) { - String trimmedNotes = payload.getNotes().trim(); - session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes); - } - - QuoteSession saved = quoteSessionRepo.save(session); - return ResponseEntity.ok(toCadInvoiceDto(saved)); + return ResponseEntity.ok(adminOperationsControllerService.createOrUpdateCadInvoice(payload)); } @DeleteMapping("/sessions/{sessionId}") @Transactional public ResponseEntity deleteQuoteSession(@PathVariable UUID sessionId) { - QuoteSession session = quoteSessionRepo.findById(sessionId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); - - if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { - throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); - } - - deleteSessionFiles(sessionId); - quoteSessionRepo.delete(session); + adminOperationsControllerService.deleteQuoteSession(sessionId); return ResponseEntity.noContent().build(); } - - private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { - AdminContactRequestDto dto = new AdminContactRequestDto(); - dto.setId(request.getId()); - dto.setRequestType(request.getRequestType()); - dto.setCustomerType(request.getCustomerType()); - dto.setEmail(request.getEmail()); - dto.setPhone(request.getPhone()); - dto.setName(request.getName()); - dto.setCompanyName(request.getCompanyName()); - dto.setStatus(request.getStatus()); - dto.setCreatedAt(request.getCreatedAt()); - return dto; - } - - private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { - AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); - dto.setId(attachment.getId()); - dto.setOriginalFilename(attachment.getOriginalFilename()); - dto.setMimeType(attachment.getMimeType()); - dto.setFileSizeBytes(attachment.getFileSizeBytes()); - dto.setCreatedAt(attachment.getCreatedAt()); - return dto; - } - - private AdminContactRequestDetailDto toContactRequestDetailDto( - CustomQuoteRequest request, - List attachments - ) { - AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); - dto.setId(request.getId()); - dto.setRequestType(request.getRequestType()); - dto.setCustomerType(request.getCustomerType()); - dto.setEmail(request.getEmail()); - dto.setPhone(request.getPhone()); - dto.setName(request.getName()); - dto.setCompanyName(request.getCompanyName()); - dto.setContactPerson(request.getContactPerson()); - dto.setMessage(request.getMessage()); - dto.setStatus(request.getStatus()); - dto.setCreatedAt(request.getCreatedAt()); - dto.setUpdatedAt(request.getUpdatedAt()); - dto.setAttachments(attachments); - return dto; - } - - private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { - AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); - dto.setId(session.getId()); - dto.setStatus(session.getStatus()); - dto.setMaterialCode(session.getMaterialCode()); - dto.setCreatedAt(session.getCreatedAt()); - dto.setExpiresAt(session.getExpiresAt()); - dto.setConvertedOrderId(session.getConvertedOrderId()); - dto.setSourceRequestId(session.getSourceRequestId()); - dto.setCadHours(session.getCadHours()); - dto.setCadHourlyRateChf(session.getCadHourlyRateChf()); - dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session)); - return dto; - } - - private boolean isCadSessionRecord(QuoteSession session) { - if ("CAD_ACTIVE".equals(session.getStatus())) { - return true; - } - if (!"CONVERTED".equals(session.getStatus())) { - return false; - } - BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; - return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null; - } - - private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) { - List items = quoteLineItemRepo.findByQuoteSessionId(session.getId()); - QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); - - AdminCadInvoiceDto dto = new AdminCadInvoiceDto(); - dto.setSessionId(session.getId()); - dto.setSessionStatus(session.getStatus()); - dto.setSourceRequestId(session.getSourceRequestId()); - dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); - dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); - dto.setCadTotalChf(totals.cadTotalChf()); - dto.setPrintItemsTotalChf(totals.printItemsTotalChf()); - dto.setSetupCostChf(totals.setupCostChf()); - dto.setShippingCostChf(totals.shippingCostChf()); - dto.setGrandTotalChf(totals.grandTotalChf()); - dto.setConvertedOrderId(session.getConvertedOrderId()); - dto.setCheckoutPath("/checkout/cad?session=" + session.getId()); - dto.setNotes(session.getNotes()); - dto.setCreatedAt(session.getCreatedAt()); - - if (session.getConvertedOrderId() != null) { - Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null); - dto.setConvertedOrderStatus(order != null ? order.getStatus() : null); - } - return dto; - } - - private void deleteSessionFiles(UUID sessionId) { - Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); - if (!Files.exists(sessionDir)) { - return; - } - - try (Stream walk = Files.walk(sessionDir)) { - walk.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (IOException | UncheckedIOException e) { - logger.error("Failed to delete files for session {}", sessionId, e); - throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); - } - } } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index b098507..d6f5f68 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -8,6 +8,10 @@ public class OrderItemDto { private String originalFilename; private String materialCode; private String colorCode; + private Long filamentVariantId; + private String filamentVariantDisplayName; + private String filamentColorName; + private String filamentColorHex; private String quality; private BigDecimal nozzleDiameterMm; private BigDecimal layerHeightMm; @@ -33,6 +37,18 @@ public class OrderItemDto { public String getColorCode() { return colorCode; } public void setColorCode(String colorCode) { this.colorCode = colorCode; } + public Long getFilamentVariantId() { return filamentVariantId; } + public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; } + + public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; } + public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; } + + public String getFilamentColorName() { return filamentColorName; } + public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; } + + public String getFilamentColorHex() { return filamentColorHex; } + public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } + public String getQuality() { return quality; } public void setQuality(String quality) { this.quality = quality; } diff --git a/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java index 6cfdbb6..94d4bfe 100644 --- a/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java @@ -3,5 +3,9 @@ package com.printcalculator.repository; import com.printcalculator.entity.NozzleOption; import org.springframework.data.jpa.repository.JpaRepository; +import java.math.BigDecimal; +import java.util.Optional; + public interface NozzleOptionRepository extends JpaRepository { -} \ No newline at end of file + Optional findFirstByNozzleDiameterMmAndIsActiveTrue(BigDecimal nozzleDiameterMm); +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 33236fc..ebef7d8 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -157,7 +157,7 @@ public class OrderService { order.setSubtotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO); - order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); + order.setSetupCostChf(totals.setupCostChf()); order.setShippingCostChf(totals.shippingCostChf()); order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus())); order.setSourceRequestId(session.getSourceRequestId()); diff --git a/backend/src/main/java/com/printcalculator/service/ProfileManager.java b/backend/src/main/java/com/printcalculator/service/ProfileManager.java index 67eee52..6411bbf 100644 --- a/backend/src/main/java/com/printcalculator/service/ProfileManager.java +++ b/backend/src/main/java/com/printcalculator/service/ProfileManager.java @@ -7,10 +7,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; import java.util.Iterator; import java.util.Optional; import java.util.logging.Logger; @@ -20,16 +24,21 @@ import java.util.HashMap; import java.util.List; import java.util.LinkedHashSet; import java.util.Set; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class ProfileManager { private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); + private static final Pattern LAYER_MM_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)?)mm\\b", Pattern.CASE_INSENSITIVE); private final String profilesRoot; private final Path resolvedProfilesRoot; private final ObjectMapper mapper; private final Map profileAliases; + private volatile List cachedProcessProfiles; public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) { this.profilesRoot = profilesRoot; @@ -68,6 +77,61 @@ public class ProfileManager { return resolveInheritance(profilePath); } + public List findCompatibleProcessLayers(String machineProfileName) { + if (machineProfileName == null || machineProfileName.isBlank()) { + return List.of(); + } + + Set layers = new LinkedHashSet<>(); + for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) { + if (meta.compatiblePrinters().contains(machineProfileName) && meta.layerHeightMm() != null) { + layers.add(meta.layerHeightMm()); + } + } + if (layers.isEmpty()) { + return List.of(); + } + + List sorted = new ArrayList<>(layers); + sorted.sort(Comparator.naturalOrder()); + return sorted; + } + + public Optional findCompatibleProcessProfileName(String machineProfileName, + BigDecimal layerHeightMm, + String qualityHint) { + if (machineProfileName == null || machineProfileName.isBlank() || layerHeightMm == null) { + return Optional.empty(); + } + + BigDecimal normalizedLayer = layerHeightMm.setScale(3, RoundingMode.HALF_UP); + String normalizedQuality = String.valueOf(qualityHint == null ? "" : qualityHint) + .trim() + .toLowerCase(Locale.ROOT); + + List candidates = new ArrayList<>(); + for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) { + if (!meta.compatiblePrinters().contains(machineProfileName)) { + continue; + } + if (meta.layerHeightMm() == null || meta.layerHeightMm().compareTo(normalizedLayer) != 0) { + continue; + } + candidates.add(meta); + } + + if (candidates.isEmpty()) { + return Optional.empty(); + } + + candidates.sort(Comparator + .comparingInt((ProcessProfileMeta meta) -> scoreProcessForQuality(meta.name(), normalizedQuality)) + .reversed() + .thenComparing(ProcessProfileMeta::name, String.CASE_INSENSITIVE_ORDER)); + + return Optional.ofNullable(candidates.get(0).name()); + } + private Path findProfileFile(String name, String type) { if (!Files.isDirectory(resolvedProfilesRoot)) { logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); @@ -215,4 +279,125 @@ public class ProfileManager { } return "any"; } + + private List getOrLoadProcessProfiles() { + List cached = cachedProcessProfiles; + if (cached != null) { + return cached; + } + + synchronized (this) { + if (cachedProcessProfiles != null) { + return cachedProcessProfiles; + } + + List loaded = new ArrayList<>(); + if (!Files.isDirectory(resolvedProfilesRoot)) { + cachedProcessProfiles = Collections.emptyList(); + return cachedProcessProfiles; + } + + try (Stream stream = Files.walk(resolvedProfilesRoot)) { + List processFiles = stream + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) + .filter(path -> pathContainsSegment(path, "process")) + .sorted() + .toList(); + + for (Path processFile : processFiles) { + try { + JsonNode node = mapper.readTree(processFile.toFile()); + if (!"process".equalsIgnoreCase(node.path("type").asText())) { + continue; + } + + String name = node.path("name").asText(""); + if (name.isBlank()) { + continue; + } + + BigDecimal layer = extractLayerHeightFromProfileName(name); + if (layer == null) { + continue; + } + + Set compatiblePrinters = new LinkedHashSet<>(); + JsonNode compatibleNode = node.path("compatible_printers"); + if (compatibleNode.isArray()) { + compatibleNode.forEach(value -> { + String printer = value.asText("").trim(); + if (!printer.isBlank()) { + compatiblePrinters.add(printer); + } + }); + } + + if (compatiblePrinters.isEmpty()) { + continue; + } + + loaded.add(new ProcessProfileMeta(name, layer, compatiblePrinters)); + } catch (Exception ignored) { + // Ignore malformed or non-process JSON files. + } + } + } catch (IOException e) { + logger.warning("Failed to scan process profiles: " + e.getMessage()); + } + + cachedProcessProfiles = List.copyOf(loaded); + return cachedProcessProfiles; + } + } + + private BigDecimal extractLayerHeightFromProfileName(String profileName) { + if (profileName == null) { + return null; + } + Matcher matcher = LAYER_MM_PATTERN.matcher(profileName.trim()); + if (!matcher.find()) { + return null; + } + try { + return new BigDecimal(matcher.group(1)).setScale(3, RoundingMode.HALF_UP); + } catch (NumberFormatException ex) { + return null; + } + } + + private int scoreProcessForQuality(String processName, String qualityHint) { + String normalizedName = String.valueOf(processName == null ? "" : processName) + .toLowerCase(Locale.ROOT); + if (qualityHint == null || qualityHint.isBlank()) { + return 0; + } + + return switch (qualityHint) { + case "draft" -> { + if (normalizedName.contains("extra draft")) yield 30; + if (normalizedName.contains("draft")) yield 20; + if (normalizedName.contains("standard")) yield 10; + yield 0; + } + case "extra_fine", "high", "high_definition" -> { + if (normalizedName.contains("extra fine")) yield 30; + if (normalizedName.contains("high quality")) yield 25; + if (normalizedName.contains("fine")) yield 20; + if (normalizedName.contains("standard")) yield 5; + yield 0; + } + default -> { + if (normalizedName.contains("standard")) yield 30; + if (normalizedName.contains("optimal")) yield 25; + if (normalizedName.contains("strength")) yield 20; + if (normalizedName.contains("high quality")) yield 10; + if (normalizedName.contains("draft")) yield 5; + yield 0; + } + }; + } + + private record ProcessProfileMeta(String name, BigDecimal layerHeightMm, Set compatiblePrinters) { + } } diff --git a/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java index 2d1ae10..591be3f 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java @@ -3,22 +3,29 @@ package com.printcalculator.service; import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PricingPolicyRepository; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.LinkedHashSet; import java.util.Arrays; import java.util.List; +import java.util.Set; @Service public class QuoteSessionTotalsService { private final PricingPolicyRepository pricingRepo; private final QuoteCalculator quoteCalculator; + private final NozzleOptionRepository nozzleOptionRepo; - public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, QuoteCalculator quoteCalculator) { + public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, + QuoteCalculator quoteCalculator, + NozzleOptionRepository nozzleOptionRepo) { this.pricingRepo = pricingRepo; this.quoteCalculator = quoteCalculator; + this.nozzleOptionRepo = nozzleOptionRepo; } public QuoteSessionTotals compute(QuoteSession session, List items) { @@ -43,7 +50,9 @@ public class QuoteSessionTotalsService { BigDecimal cadTotal = calculateCadTotal(session); BigDecimal itemsTotal = printItemsTotal.add(cadTotal); - BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + BigDecimal baseSetupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + BigDecimal nozzleChangeCost = calculateNozzleChangeCost(items); + BigDecimal setupFee = baseSetupFee.add(nozzleChangeCost).setScale(2, RoundingMode.HALF_UP); BigDecimal shippingCost = calculateShippingCost(items); BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost); @@ -52,6 +61,8 @@ public class QuoteSessionTotalsService { globalMachineCost, cadTotal, itemsTotal, + baseSetupFee.setScale(2, RoundingMode.HALF_UP), + nozzleChangeCost, setupFee, shippingCost, grandTotal, @@ -104,6 +115,36 @@ public class QuoteSessionTotalsService { return BigDecimal.valueOf(2.00); } + private BigDecimal calculateNozzleChangeCost(List items) { + if (items == null || items.isEmpty()) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + + Set uniqueNozzles = new LinkedHashSet<>(); + for (QuoteLineItem item : items) { + if (item == null || item.getNozzleDiameterMm() == null) { + continue; + } + uniqueNozzles.add(item.getNozzleDiameterMm().setScale(2, RoundingMode.HALF_UP)); + } + + BigDecimal totalFee = BigDecimal.ZERO; + for (BigDecimal nozzle : uniqueNozzles) { + BigDecimal nozzleFee = nozzleOptionRepo + .findFirstByNozzleDiameterMmAndIsActiveTrue(nozzle) + .map(option -> option.getExtraNozzleChangeFeeChf() != null + ? option.getExtraNozzleChangeFeeChf() + : BigDecimal.ZERO) + .orElse(BigDecimal.ZERO); + + if (nozzleFee.compareTo(BigDecimal.ZERO) > 0) { + totalFee = totalFee.add(nozzleFee); + } + } + + return totalFee.setScale(2, RoundingMode.HALF_UP); + } + private int normalizeQuantity(Integer quantity) { if (quantity == null || quantity < 1) { return 1; @@ -116,6 +157,8 @@ public class QuoteSessionTotalsService { BigDecimal globalMachineCostChf, BigDecimal cadTotalChf, BigDecimal itemsTotalChf, + BigDecimal baseSetupCostChf, + BigDecimal nozzleChangeCostChf, BigDecimal setupCostChf, BigDecimal shippingCostChf, BigDecimal grandTotalChf, diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java new file mode 100644 index 0000000..1fc2de4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java @@ -0,0 +1,327 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminFilamentControllerService { + private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Set ALLOWED_FINISH_TYPES = Set.of( + "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" + ); + + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderItemRepository orderItemRepo; + + public AdminFilamentControllerService(FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderItemRepository orderItemRepo) { + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderItemRepo = orderItemRepo; + } + + public List getMaterials() { + return materialRepo.findAll().stream() + .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) + .map(this::toMaterialDto) + .toList(); + } + + public List getVariants() { + return variantRepo.findAll().stream() + .sorted(Comparator + .comparing((FilamentVariant variant) -> { + FilamentMaterialType type = variant.getFilamentMaterialType(); + return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; + }, String.CASE_INSENSITIVE_ORDER) + .thenComparing(variant -> variant.getVariantDisplayName() != null ? variant.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) + .map(this::toVariantDto) + .toList(); + } + + @Transactional + public AdminFilamentMaterialTypeDto createMaterial(AdminUpsertFilamentMaterialTypeRequest payload) { + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, null); + + FilamentMaterialType material = new FilamentMaterialType(); + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return toMaterialDto(saved); + } + + @Transactional + public AdminFilamentMaterialTypeDto updateMaterial(Long materialTypeId, AdminUpsertFilamentMaterialTypeRequest payload) { + FilamentMaterialType material = materialRepo.findById(materialTypeId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); + + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, materialTypeId); + + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return toMaterialDto(saved); + } + + @Transactional + public AdminFilamentVariantDto createVariant(AdminUpsertFilamentVariantRequest payload) { + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); + + FilamentVariant variant = new FilamentVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return toVariantDto(saved); + } + + @Transactional + public AdminFilamentVariantDto updateVariant(Long variantId, AdminUpsertFilamentVariantRequest payload) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); + + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return toVariantDto(saved); + } + + @Transactional + public void deleteVariant(Long variantId) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { + throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); + } + + variantRepo.delete(variant); + } + + private void applyMaterialPayload(FilamentMaterialType material, + AdminUpsertFilamentMaterialTypeRequest payload, + String normalizedMaterialCode) { + boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); + boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); + String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null + ? payload.getTechnicalTypeLabel().trim() + : null; + + material.setMaterialCode(normalizedMaterialCode); + material.setIsFlexible(isFlexible); + material.setIsTechnical(isTechnical); + material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() + ? technicalTypeLabel + : null); + } + + private void applyVariantPayload(FilamentVariant variant, + AdminUpsertFilamentVariantRequest payload, + FilamentMaterialType material, + String normalizedDisplayName, + String normalizedColorName) { + String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); + String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); + String normalizedBrand = normalizeOptional(payload.getBrand()); + + variant.setFilamentMaterialType(material); + variant.setVariantDisplayName(normalizedDisplayName); + variant.setColorName(normalizedColorName); + variant.setColorHex(normalizedColorHex); + variant.setFinishType(normalizedFinishType); + variant.setBrand(normalizedBrand); + variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); + variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); + variant.setCostChfPerKg(payload.getCostChfPerKg()); + variant.setStockSpools(payload.getStockSpools()); + variant.setSpoolNetKg(payload.getSpoolNetKg()); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + } + + private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { + if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); + } + return payload.getMaterialCode().trim().toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateVariantDisplayName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorHex(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { + String normalized = finishType == null || finishType.isBlank() + ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") + : finishType.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_FINISH_TYPES.contains(normalized)) { + throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { + if (payload == null || payload.getMaterialTypeId() == null) { + throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); + } + + return materialRepo.findById(payload.getMaterialTypeId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); + } + + private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { + if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); + } + validateNumeric63(payload.getStockSpools(), "Stock spools", true); + validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); + } + + private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { + if (value == null) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); + } + + if (allowZero) { + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); + } + } else if (value.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); + } + + if (value.scale() > 3) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); + } + + if (value.compareTo(MAX_NUMERIC_6_3) > 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); + } + } + + private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { + materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { + if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { + throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); + } + }); + } + + private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { + variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { + if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); + } + }); + } + + private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { + AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); + dto.setId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setIsFlexible(material.getIsFlexible()); + dto.setIsTechnical(material.getIsTechnical()); + dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); + return dto; + } + + private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { + AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); + dto.setId(variant.getId()); + + FilamentMaterialType material = variant.getFilamentMaterialType(); + if (material != null) { + dto.setMaterialTypeId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setMaterialIsFlexible(material.getIsFlexible()); + dto.setMaterialIsTechnical(material.getIsTechnical()); + dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); + } + + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setFinishType(variant.getFinishType()); + dto.setBrand(variant.getBrand()); + dto.setIsMatte(variant.getIsMatte()); + dto.setIsSpecial(variant.getIsSpecial()); + dto.setCostChfPerKg(variant.getCostChfPerKg()); + dto.setStockSpools(variant.getStockSpools()); + dto.setSpoolNetKg(variant.getSpoolNetKg()); + BigDecimal stockKg = BigDecimal.ZERO; + if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { + stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); + } + dto.setStockKg(stockKg); + dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); + dto.setIsActive(variant.getIsActive()); + dto.setCreatedAt(variant.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java new file mode 100644 index 0000000..1291c1a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java @@ -0,0 +1,469 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCadInvoiceCreateRequest; +import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestAttachmentDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminContactRequestDto; +import com.printcalculator.dto.AdminFilamentStockDto; +import com.printcalculator.dto.AdminQuoteSessionDto; +import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantStockKg; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.domain.Sort; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminOperationsControllerService { + private static final Logger logger = LoggerFactory.getLogger(AdminOperationsControllerService.class); + private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Set CONTACT_REQUEST_ALLOWED_STATUSES = Set.of( + "NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED" + ); + + private final FilamentVariantStockKgRepository filamentStockRepo; + private final FilamentVariantRepository filamentVariantRepo; + private final CustomQuoteRequestRepository customQuoteRequestRepo; + private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderRepository orderRepo; + private final PricingPolicyRepository pricingRepo; + private final QuoteSessionTotalsService quoteSessionTotalsService; + + public AdminOperationsControllerService(FilamentVariantStockKgRepository filamentStockRepo, + FilamentVariantRepository filamentVariantRepo, + CustomQuoteRequestRepository customQuoteRequestRepo, + CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, + QuoteSessionRepository quoteSessionRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderRepository orderRepo, + PricingPolicyRepository pricingRepo, + QuoteSessionTotalsService quoteSessionTotalsService) { + this.filamentStockRepo = filamentStockRepo; + this.filamentVariantRepo = filamentVariantRepo; + this.customQuoteRequestRepo = customQuoteRequestRepo; + this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderRepo = orderRepo; + this.pricingRepo = pricingRepo; + this.quoteSessionTotalsService = quoteSessionTotalsService; + } + + public List getFilamentStock() { + List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); + Set variantIds = stocks.stream() + .map(FilamentVariantStockKg::getFilamentVariantId) + .collect(Collectors.toSet()); + + Map variantsById; + if (variantIds.isEmpty()) { + variantsById = Collections.emptyMap(); + } else { + variantsById = filamentVariantRepo.findAllById(variantIds).stream() + .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); + } + + return stocks.stream().map(stock -> { + FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); + AdminFilamentStockDto dto = new AdminFilamentStockDto(); + dto.setFilamentVariantId(stock.getFilamentVariantId()); + dto.setStockSpools(stock.getStockSpools()); + dto.setSpoolNetKg(stock.getSpoolNetKg()); + dto.setStockKg(stock.getStockKg()); + BigDecimal grams = stock.getStockKg() != null + ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) + : BigDecimal.ZERO; + dto.setStockFilamentGrams(grams); + + if (variant != null) { + dto.setMaterialCode( + variant.getFilamentMaterialType() != null + ? variant.getFilamentMaterialType().getMaterialCode() + : "UNKNOWN" + ); + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setActive(variant.getIsActive()); + } else { + dto.setMaterialCode("UNKNOWN"); + dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); + dto.setColorName("-"); + dto.setActive(false); + } + + return dto; + }).toList(); + } + + public List getContactRequests() { + return customQuoteRequestRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt")) + .stream() + .map(this::toContactRequestDto) + .toList(); + } + + public AdminContactRequestDetailDto getContactRequestDetail(UUID requestId) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return toContactRequestDetailDto(request, attachments); + } + + @Transactional + public AdminContactRequestDetailDto updateContactRequestStatus(UUID requestId, + AdminUpdateContactRequestStatusRequest payload) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + String requestedStatus = payload != null && payload.getStatus() != null + ? payload.getStatus().trim().toUpperCase(Locale.ROOT) + : ""; + + if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES) + ); + } + + request.setStatus(requestedStatus); + request.setUpdatedAt(OffsetDateTime.now()); + CustomQuoteRequest saved = customQuoteRequestRepo.save(request); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return toContactRequestDetailDto(saved, attachments); + } + + public ResponseEntity downloadContactRequestAttachment(UUID requestId, UUID attachmentId) { + CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); + + if (!attachment.getRequest().getId().equals(requestId)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); + } + + String relativePath = attachment.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; + if (!relativePath.startsWith(expectedPrefix)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); + if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + if (!Files.exists(filePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + try { + Resource resource = new UrlResource(filePath.toUri()); + if (!resource.exists() || !resource.isReadable()) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + String mimeType = attachment.getMimeType(); + if (mimeType != null && !mimeType.isBlank()) { + try { + mediaType = MediaType.parseMediaType(mimeType); + } catch (Exception ignored) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = attachment.getOriginalFilename(); + if (filename == null || filename.isBlank()) { + filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() + ? attachment.getStoredFilename() + : "attachment-" + attachmentId; + } + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (MalformedURLException e) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + } + + public List getQuoteSessions() { + return quoteSessionRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt")) + .stream() + .map(this::toQuoteSessionDto) + .toList(); + } + + public List getCadInvoices() { + return quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) + .stream() + .filter(this::isCadSessionRecord) + .map(this::toCadInvoiceDto) + .toList(); + } + + @Transactional + public AdminCadInvoiceDto createOrUpdateCadInvoice(AdminCadInvoiceCreateRequest payload) { + if (payload == null || payload.getCadHours() == null) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours is required"); + } + + BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP); + if (cadHours.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0"); + } + + BigDecimal cadRate = payload.getCadHourlyRateChf(); + if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) { + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + cadRate = policy != null && policy.getCadCostChfPerHour() != null + ? policy.getCadCostChfPerHour() + : BigDecimal.ZERO; + } + cadRate = cadRate.setScale(2, RoundingMode.HALF_UP); + + QuoteSession session; + if (payload.getSessionId() != null) { + session = quoteSessionRepo.findById(payload.getSessionId()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + } else { + session = new QuoteSession(); + session.setStatus("CAD_ACTIVE"); + session.setPricingVersion("v1"); + session.setMaterialCode("PLA"); + session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); + session.setLayerHeightMm(BigDecimal.valueOf(0.2)); + session.setInfillPattern("grid"); + session.setInfillPercent(20); + session.setSupportsEnabled(false); + session.setSetupCostChf(BigDecimal.ZERO); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + } + + if ("CONVERTED".equals(session.getStatus())) { + throw new ResponseStatusException(CONFLICT, "Session already converted to order"); + } + + if (payload.getSourceRequestId() != null) { + if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) { + throw new ResponseStatusException(NOT_FOUND, "Source request not found"); + } + session.setSourceRequestId(payload.getSourceRequestId()); + } else { + session.setSourceRequestId(null); + } + + session.setStatus("CAD_ACTIVE"); + session.setCadHours(cadHours); + session.setCadHourlyRateChf(cadRate); + if (payload.getNotes() != null) { + String trimmedNotes = payload.getNotes().trim(); + session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes); + } + + QuoteSession saved = quoteSessionRepo.save(session); + return toCadInvoiceDto(saved); + } + + @Transactional + public void deleteQuoteSession(UUID sessionId) { + QuoteSession session = quoteSessionRepo.findById(sessionId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + + if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { + throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); + } + + deleteSessionFiles(sessionId); + quoteSessionRepo.delete(session); + } + + private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { + AdminContactRequestDto dto = new AdminContactRequestDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + return dto; + } + + private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { + AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); + dto.setId(attachment.getId()); + dto.setOriginalFilename(attachment.getOriginalFilename()); + dto.setMimeType(attachment.getMimeType()); + dto.setFileSizeBytes(attachment.getFileSizeBytes()); + dto.setCreatedAt(attachment.getCreatedAt()); + return dto; + } + + private AdminContactRequestDetailDto toContactRequestDetailDto(CustomQuoteRequest request, + List attachments) { + AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setContactPerson(request.getContactPerson()); + dto.setMessage(request.getMessage()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + dto.setUpdatedAt(request.getUpdatedAt()); + dto.setAttachments(attachments); + return dto; + } + + private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { + AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); + dto.setId(session.getId()); + dto.setStatus(session.getStatus()); + dto.setMaterialCode(session.getMaterialCode()); + dto.setCreatedAt(session.getCreatedAt()); + dto.setExpiresAt(session.getExpiresAt()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours()); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf()); + dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session)); + return dto; + } + + private boolean isCadSessionRecord(QuoteSession session) { + if ("CAD_ACTIVE".equals(session.getStatus())) { + return true; + } + if (!"CONVERTED".equals(session.getStatus())) { + return false; + } + BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; + return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null; + } + + private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) { + List items = quoteLineItemRepo.findByQuoteSessionId(session.getId()); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + + AdminCadInvoiceDto dto = new AdminCadInvoiceDto(); + dto.setSessionId(session.getId()); + dto.setSessionStatus(session.getStatus()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); + dto.setCadTotalChf(totals.cadTotalChf()); + dto.setPrintItemsTotalChf(totals.printItemsTotalChf()); + dto.setSetupCostChf(totals.setupCostChf()); + dto.setShippingCostChf(totals.shippingCostChf()); + dto.setGrandTotalChf(totals.grandTotalChf()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setCheckoutPath("/checkout/cad?session=" + session.getId()); + dto.setNotes(session.getNotes()); + dto.setCreatedAt(session.getCreatedAt()); + + if (session.getConvertedOrderId() != null) { + Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null); + dto.setConvertedOrderStatus(order != null ? order.getStatus() : null); + } + return dto; + } + + private void deleteSessionFiles(UUID sessionId) { + Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); + if (!Files.exists(sessionDir)) { + return; + } + + try (Stream walk = Files.walk(sessionDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException | UncheckedIOException e) { + logger.error("Failed to delete files for session {}", sessionId, e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index 22327e0..dfda322 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -263,6 +263,18 @@ public class AdminOrderControllerService { itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getFilamentVariant() != null) { + itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); + itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); + itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); + } + itemDto.setQuality(item.getQuality()); + itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); + itemDto.setLayerHeightMm(item.getLayerHeightMm()); + itemDto.setInfillPercent(item.getInfillPercent()); + itemDto.setInfillPattern(item.getInfillPattern()); + itemDto.setSupportsEnabled(item.getSupportsEnabled()); itemDto.setQuantity(item.getQuantity()); itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); itemDto.setMaterialGrams(item.getMaterialGrams()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 03d6163..4baca4b 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -317,6 +317,12 @@ public class OrderControllerService { itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getFilamentVariant() != null) { + itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); + itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); + itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); + } itemDto.setQuality(item.getQuality()); itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); itemDto.setLayerHeightMm(item.getLayerHeightMm()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index 9797c7f..38175b0 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -11,6 +11,7 @@ import com.printcalculator.model.QuoteResult; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.ProfileManager; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.SlicerService; import com.printcalculator.service.storage.ClamAVService; @@ -41,6 +42,7 @@ public class QuoteSessionItemService { private final ClamAVService clamAVService; private final QuoteStorageService quoteStorageService; private final QuoteSessionSettingsService settingsService; + private final ProfileManager profileManager; public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo, QuoteSessionRepository sessionRepo, @@ -49,7 +51,8 @@ public class QuoteSessionItemService { OrcaProfileResolver orcaProfileResolver, ClamAVService clamAVService, QuoteStorageService quoteStorageService, - QuoteSessionSettingsService settingsService) { + QuoteSessionSettingsService settingsService, + ProfileManager profileManager) { this.lineItemRepo = lineItemRepo; this.sessionRepo = sessionRepo; this.slicerService = slicerService; @@ -58,6 +61,7 @@ public class QuoteSessionItemService { this.clamAVService = clamAVService; this.quoteStorageService = quoteStorageService; this.settingsService = settingsService; + this.profileManager = profileManager; } public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException { @@ -109,7 +113,12 @@ public class QuoteSessionItemService { } OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); - String processProfile = resolveProcessProfile(settings); + String processProfile = resolveProcessProfile( + settings, + profiles.machineProfileName(), + nozzleDiameter, + layerHeight + ); Map processOverrides = new HashMap<>(); processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString()); @@ -180,7 +189,29 @@ public class QuoteSessionItemService { } } - private String resolveProcessProfile(PrintSettingsDto settings) { + private String resolveProcessProfile(PrintSettingsDto settings, + String machineProfileName, + BigDecimal nozzleDiameter, + BigDecimal layerHeight) { + if (machineProfileName == null || machineProfileName.isBlank() || layerHeight == null) { + return resolveLegacyProcessProfile(settings); + } + + String qualityHint = settingsService.resolveQuality(settings, layerHeight); + return profileManager + .findCompatibleProcessProfileName(machineProfileName, layerHeight, qualityHint) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Layer height " + layerHeight.stripTrailingZeros().toPlainString() + + " is not available for nozzle " + + (nozzleDiameter != null + ? nozzleDiameter.stripTrailingZeros().toPlainString() + : "-") + + " on printer profile " + machineProfileName + )); + } + + private String resolveLegacyProcessProfile(PrintSettingsDto settings) { if (settings.getLayerHeight() == null) { return "standard"; } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index f5e8721..3652586 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -34,6 +34,9 @@ public class QuoteSessionResponseAssembler { response.put("printItemsTotalChf", totals.printItemsTotalChf()); response.put("cadTotalChf", totals.cadTotalChf()); response.put("itemsTotalChf", totals.itemsTotalChf()); + response.put("baseSetupCostChf", totals.baseSetupCostChf()); + response.put("nozzleChangeCostChf", totals.nozzleChangeCostChf()); + response.put("setupCostChf", totals.setupCostChf()); response.put("shippingCostChf", totals.shippingCostChf()); response.put("globalMachineCostChf", totals.globalMachineCostChf()); response.put("grandTotalChf", totals.grandTotalChf()); diff --git a/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java b/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java new file mode 100644 index 0000000..60f6890 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java @@ -0,0 +1,216 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Service +public class ContactRequestLocalizationService { + + public String applyCustomerContactRequestTexts(Map templateData, String language, UUID requestId) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Contact request received"); + templateData.put("headlineText", "We received your contact request"); + templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ","); + templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible."); + templateData.put("requestIdHintText", "Please keep this request ID for future order references:"); + templateData.put("detailsTitleText", "Request details"); + templateData.put("labelRequestId", "Request ID"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Request type"); + templateData.put("labelCustomerType", "Customer type"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Company"); + templateData.put("labelContactPerson", "Contact person"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Phone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Attachments"); + templateData.put("supportText", "If you need help, reply to this email."); + templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab."); + yield "We received your contact request #" + requestId + " - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Kontaktanfrage erhalten"); + templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten"); + templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ","); + templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich."); + templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:"); + templateData.put("detailsTitleText", "Anfragedetails"); + templateData.put("labelRequestId", "Anfrage-ID"); + templateData.put("labelDate", "Datum"); + templateData.put("labelRequestType", "Anfragetyp"); + templateData.put("labelCustomerType", "Kundentyp"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Firma"); + templateData.put("labelContactPerson", "Kontaktperson"); + templateData.put("labelEmail", "E-Mail"); + templateData.put("labelPhone", "Telefon"); + templateData.put("labelMessage", "Nachricht"); + templateData.put("labelAttachments", "Anhaenge"); + templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail."); + templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab."); + yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Demande de contact recue"); + templateData.put("headlineText", "Nous avons recu votre demande de contact"); + templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ","); + templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible."); + templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :"); + templateData.put("detailsTitleText", "Details de la demande"); + templateData.put("labelRequestId", "ID de demande"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Type de demande"); + templateData.put("labelCustomerType", "Type de client"); + templateData.put("labelName", "Nom"); + templateData.put("labelCompany", "Entreprise"); + templateData.put("labelContactPerson", "Contact"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telephone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Pieces jointes"); + templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); + templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab."); + yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Richiesta di contatto ricevuta"); + templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto"); + templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ","); + templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile."); + templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:"); + templateData.put("detailsTitleText", "Dettagli richiesta"); + templateData.put("labelRequestId", "ID richiesta"); + templateData.put("labelDate", "Data"); + templateData.put("labelRequestType", "Tipo richiesta"); + templateData.put("labelCustomerType", "Tipo cliente"); + templateData.put("labelName", "Nome"); + templateData.put("labelCompany", "Azienda"); + templateData.put("labelContactPerson", "Contatto"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telefono"); + templateData.put("labelMessage", "Messaggio"); + templateData.put("labelAttachments", "Allegati"); + templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email."); + templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab."); + yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab"; + } + }; + } + + public String localizeRequestType(String requestType, String language) { + if (requestType == null || requestType.isBlank()) { + return "-"; + } + + String normalized = requestType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "custom", "print_service" -> "Custom part request"; + case "series" -> "Series production request"; + case "consult", "design_service" -> "Consultation request"; + case "question" -> "General question"; + default -> requestType; + }; + case "de" -> switch (normalized) { + case "custom", "print_service" -> "Anfrage fuer Einzelteil"; + case "series" -> "Anfrage fuer Serienproduktion"; + case "consult", "design_service" -> "Beratungsanfrage"; + case "question" -> "Allgemeine Frage"; + default -> requestType; + }; + case "fr" -> switch (normalized) { + case "custom", "print_service" -> "Demande de piece personnalisee"; + case "series" -> "Demande de production en serie"; + case "consult", "design_service" -> "Demande de conseil"; + case "question" -> "Question generale"; + default -> requestType; + }; + default -> switch (normalized) { + case "custom", "print_service" -> "Richiesta pezzo personalizzato"; + case "series" -> "Richiesta produzione in serie"; + case "consult", "design_service" -> "Richiesta consulenza"; + case "question" -> "Domanda generale"; + default -> requestType; + }; + }; + } + + public String localizeCustomerType(String customerType, String language) { + if (customerType == null || customerType.isBlank()) { + return "-"; + } + String normalized = customerType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "private" -> "Private"; + case "business" -> "Business"; + default -> customerType; + }; + case "de" -> switch (normalized) { + case "private" -> "Privat"; + case "business" -> "Unternehmen"; + default -> customerType; + }; + case "fr" -> switch (normalized) { + case "private" -> "Prive"; + case "business" -> "Entreprise"; + default -> customerType; + }; + default -> switch (normalized) { + case "private" -> "Privato"; + case "business" -> "Azienda"; + default -> customerType; + }; + }; + } + + public Locale localeForLanguage(String language) { + return switch (language) { + case "en" -> Locale.ENGLISH; + case "de" -> Locale.GERMAN; + case "fr" -> Locale.FRENCH; + default -> Locale.ITALIAN; + }; + } + + public String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + String normalized = language.toLowerCase(Locale.ROOT).trim(); + if (normalized.startsWith("en")) { + return "en"; + } + if (normalized.startsWith("de")) { + return "de"; + } + if (normalized.startsWith("fr")) { + return "fr"; + } + return "it"; + } + + public String resolveRecipientName(CustomQuoteRequest request, String language) { + if (request.getName() != null && !request.getName().isBlank()) { + return request.getName().trim(); + } + if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { + return request.getContactPerson().trim(); + } + if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) { + return request.getCompanyName().trim(); + } + return switch (language) { + case "en" -> "customer"; + case "de" -> "Kunde"; + case "fr" -> "client"; + default -> "cliente"; + }; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java new file mode 100644 index 0000000..c5ff2b6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java @@ -0,0 +1,155 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.service.storage.ClamAVService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +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.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +@Transactional(readOnly = true) +public class CustomQuoteRequestAttachmentService { + + private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( + "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" + ); + private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/vnd.rar", + "application/x-7z-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-bzip2", + "application/x-xz", + "application/zstd", + "application/x-zstd" + ); + + private final CustomQuoteRequestAttachmentRepository attachmentRepo; + private final ClamAVService clamAVService; + + public CustomQuoteRequestAttachmentService(CustomQuoteRequestAttachmentRepository attachmentRepo, + ClamAVService clamAVService) { + this.attachmentRepo = attachmentRepo; + this.clamAVService = clamAVService; + } + + @Transactional + public int storeAttachments(CustomQuoteRequest request, List files) throws IOException { + if (files == null || files.isEmpty()) { + return 0; + } + if (files.size() > 15) { + throw new IOException("Too many files. Max 15 allowed."); + } + + int attachmentsCount = 0; + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + if (isCompressedFile(file)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Compressed files are not allowed."); + } + + clamAVService.scan(file.getInputStream()); + + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); + attachment.setRequest(request); + attachment.setOriginalFilename(file.getOriginalFilename()); + attachment.setMimeType(file.getContentType()); + attachment.setFileSizeBytes(file.getSize()); + attachment.setCreatedAt(OffsetDateTime.now()); + attachment.setStoredFilename(UUID.randomUUID() + ".upload"); + attachment.setStoredRelativePath("PENDING"); + + attachment = attachmentRepo.save(attachment); + + Path relativePath = Path.of( + "quote-requests", + request.getId().toString(), + "attachments", + attachment.getId().toString(), + attachment.getStoredFilename() + ); + attachment.setStoredRelativePath(relativePath.toString()); + attachmentRepo.save(attachment); + + Path absolutePath = resolveWithinStorageRoot(relativePath); + Files.createDirectories(absolutePath.getParent()); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); + } + attachmentsCount++; + } + + return attachmentsCount; + } + + private String getExtension(String filename) { + if (filename == null) { + return "dat"; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "dat"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "dat"; + } + + private boolean isCompressedFile(MultipartFile file) { + String ext = getExtension(file.getOriginalFilename()); + if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { + return true; + } + String mime = file.getContentType(); + return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase(Locale.ROOT)); + } + + private Path resolveWithinStorageRoot(Path relativePath) { + try { + Path normalizedRelative = relativePath.normalize(); + if (normalizedRelative.isAbsolute()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); + if (!absolutePath.startsWith(STORAGE_ROOT)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + return absolutePath; + } catch (InvalidPathException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java new file mode 100644 index 0000000..d2bf728 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java @@ -0,0 +1,68 @@ +package com.printcalculator.service.request; + +import com.printcalculator.dto.QuoteRequestDto; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class CustomQuoteRequestControllerService { + + private final CustomQuoteRequestRepository requestRepo; + private final CustomQuoteRequestAttachmentService attachmentService; + private final CustomQuoteRequestNotificationService notificationService; + + public CustomQuoteRequestControllerService(CustomQuoteRequestRepository requestRepo, + CustomQuoteRequestAttachmentService attachmentService, + CustomQuoteRequestNotificationService notificationService) { + this.requestRepo = requestRepo; + this.attachmentService = attachmentService; + this.notificationService = notificationService; + } + + @Transactional + public CustomQuoteRequest createCustomQuoteRequest(QuoteRequestDto requestDto, List files) throws IOException { + validateConsents(requestDto); + + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setRequestType(requestDto.getRequestType()); + request.setCustomerType(requestDto.getCustomerType()); + request.setEmail(requestDto.getEmail()); + request.setPhone(requestDto.getPhone()); + request.setName(requestDto.getName()); + request.setCompanyName(requestDto.getCompanyName()); + request.setContactPerson(requestDto.getContactPerson()); + request.setMessage(requestDto.getMessage()); + request.setStatus("PENDING"); + request.setCreatedAt(OffsetDateTime.now()); + request.setUpdatedAt(OffsetDateTime.now()); + + request = requestRepo.save(request); + + int attachmentsCount = attachmentService.storeAttachments(request, files); + notificationService.sendNotifications(request, attachmentsCount, requestDto.getLanguage()); + + return request; + } + + public Optional getCustomQuoteRequest(UUID id) { + return requestRepo.findById(id); + } + + private void validateConsents(QuoteRequestDto requestDto) { + if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Accettazione Termini e Privacy obbligatoria."); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java new file mode 100644 index 0000000..6c6d53c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.service.email.EmailNotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.HashMap; +import java.util.Map; + +@Service +public class CustomQuoteRequestNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestNotificationService.class); + + private final EmailNotificationService emailNotificationService; + private final ContactRequestLocalizationService localizationService; + + @Value("${app.mail.contact-request.admin.enabled:true}") + private boolean contactRequestAdminMailEnabled; + + @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") + private String contactRequestAdminMailAddress; + + @Value("${app.mail.contact-request.customer.enabled:true}") + private boolean contactRequestCustomerMailEnabled; + + public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, + ContactRequestLocalizationService localizationService) { + this.emailNotificationService = emailNotificationService; + this.localizationService = localizationService; + } + + public void sendNotifications(CustomQuoteRequest request, int attachmentsCount, String rawLanguage) { + String language = localizationService.normalizeLanguage(rawLanguage); + sendAdminContactRequestNotification(request, attachmentsCount); + sendCustomerContactRequestConfirmation(request, attachmentsCount, language); + } + + private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) { + if (!contactRequestAdminMailEnabled) { + return; + } + if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) { + logger.warn("Contact request admin notification enabled but no admin address configured."); + return; + } + + Map templateData = new HashMap<>(); + templateData.put("requestId", request.getId()); + templateData.put("createdAt", request.getCreatedAt()); + templateData.put("requestType", safeValue(request.getRequestType())); + templateData.put("customerType", safeValue(request.getCustomerType())); + templateData.put("name", safeValue(request.getName())); + templateData.put("companyName", safeValue(request.getCompanyName())); + templateData.put("contactPerson", safeValue(request.getContactPerson())); + templateData.put("email", safeValue(request.getEmail())); + templateData.put("phone", safeValue(request.getPhone())); + templateData.put("message", safeValue(request.getMessage())); + templateData.put("attachmentsCount", attachmentsCount); + templateData.put("currentYear", Year.now().getValue()); + + emailNotificationService.sendEmail( + contactRequestAdminMailAddress, + "Nuova richiesta di contatto #" + request.getId(), + "contact-request-admin", + templateData + ); + } + + private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) { + if (!contactRequestCustomerMailEnabled) { + return; + } + if (request.getEmail() == null || request.getEmail().isBlank()) { + logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId()); + return; + } + + Map templateData = new HashMap<>(); + templateData.put("requestId", request.getId()); + templateData.put( + "createdAt", + request.getCreatedAt().format( + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(localizationService.localeForLanguage(language)) + ) + ); + templateData.put("recipientName", localizationService.resolveRecipientName(request, language)); + templateData.put("requestType", localizationService.localizeRequestType(request.getRequestType(), language)); + templateData.put("customerType", localizationService.localizeCustomerType(request.getCustomerType(), language)); + templateData.put("name", safeValue(request.getName())); + templateData.put("companyName", safeValue(request.getCompanyName())); + templateData.put("contactPerson", safeValue(request.getContactPerson())); + templateData.put("email", safeValue(request.getEmail())); + templateData.put("phone", safeValue(request.getPhone())); + templateData.put("message", safeValue(request.getMessage())); + templateData.put("attachmentsCount", attachmentsCount); + templateData.put("currentYear", Year.now().getValue()); + + String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); + + emailNotificationService.sendEmail( + request.getEmail(), + subject, + "contact-request-customer", + templateData + ); + } + + private String safeValue(String value) { + if (value == null || value.isBlank()) { + return "-"; + } + return value; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java index 861b5f6..7a6d4a0 100644 --- a/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java @@ -3,6 +3,8 @@ package com.printcalculator.service; import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.NozzleOption; +import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PricingPolicyRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,13 +22,15 @@ import static org.mockito.Mockito.when; class QuoteSessionTotalsServiceTest { private PricingPolicyRepository pricingRepo; private QuoteCalculator quoteCalculator; + private NozzleOptionRepository nozzleOptionRepo; private QuoteSessionTotalsService service; @BeforeEach void setUp() { pricingRepo = mock(PricingPolicyRepository.class); quoteCalculator = mock(QuoteCalculator.class); - service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator); + nozzleOptionRepo = mock(NozzleOptionRepository.class); + service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator, nozzleOptionRepo); } @Test @@ -77,6 +81,51 @@ class QuoteSessionTotalsServiceTest { assertAmountEquals("120.00", totals.grandTotalChf()); } + @Test + void compute_WithRepeatedNozzleAcrossItems_ShouldChargeNozzleFeeOnlyOncePerType() { + QuoteSession session = new QuoteSession(); + session.setSetupCostChf(new BigDecimal("2.00")); + + QuoteLineItem itemA = createItem(new BigDecimal("10.00"), 3, 3600, "0.60"); + QuoteLineItem itemB = createItem(new BigDecimal("4.00"), 2, 1200, "0.60"); + QuoteLineItem itemC = createItem(new BigDecimal("5.00"), 1, 600, "0.80"); + + PricingPolicy policy = new PricingPolicy(); + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteCalculator.calculateSessionMachineCost(eq(policy), any(BigDecimal.class))).thenReturn(BigDecimal.ZERO); + when(nozzleOptionRepo.findFirstByNozzleDiameterMmAndIsActiveTrue(new BigDecimal("0.60"))) + .thenReturn(java.util.Optional.of(nozzleOption("0.60", "1.50"))); + when(nozzleOptionRepo.findFirstByNozzleDiameterMmAndIsActiveTrue(new BigDecimal("0.80"))) + .thenReturn(java.util.Optional.of(nozzleOption("0.80", "1.50"))); + + QuoteSessionTotalsService.QuoteSessionTotals totals = service.compute(session, List.of(itemA, itemB, itemC)); + + assertAmountEquals("43.00", totals.itemsTotalChf()); + assertAmountEquals("3.00", totals.nozzleChangeCostChf()); + assertAmountEquals("5.00", totals.setupCostChf()); + assertAmountEquals("50.00", totals.grandTotalChf()); + } + + private QuoteLineItem createItem(BigDecimal unitPrice, int quantity, int printSeconds, String nozzleMm) { + QuoteLineItem item = new QuoteLineItem(); + item.setQuantity(quantity); + item.setUnitPriceChf(unitPrice); + item.setPrintTimeSeconds(printSeconds); + item.setNozzleDiameterMm(new BigDecimal(nozzleMm)); + item.setBoundingBoxXMm(new BigDecimal("10")); + item.setBoundingBoxYMm(new BigDecimal("10")); + item.setBoundingBoxZMm(new BigDecimal("10")); + return item; + } + + private NozzleOption nozzleOption(String diameterMm, String feeChf) { + NozzleOption option = new NozzleOption(); + option.setNozzleDiameterMm(new BigDecimal(diameterMm)); + option.setExtraNozzleChangeFeeChf(new BigDecimal(feeChf)); + option.setIsActive(true); + return option; + } + private void assertAmountEquals(String expected, BigDecimal actual) { assertTrue(new BigDecimal(expected).compareTo(actual) == 0, "Expected " + expected + " but got " + actual); diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java new file mode 100644 index 0000000..57e15d9 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java @@ -0,0 +1,174 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminFilamentControllerServiceTest { + + @Mock + private FilamentMaterialTypeRepository materialRepo; + @Mock + private FilamentVariantRepository variantRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private OrderItemRepository orderItemRepo; + + @InjectMocks + private AdminFilamentControllerService service; + + @Test + void createMaterial_withDuplicateCode_shouldReturnBadRequest() { + AdminUpsertFilamentMaterialTypeRequest payload = new AdminUpsertFilamentMaterialTypeRequest(); + payload.setMaterialCode("pla"); + + FilamentMaterialType existing = new FilamentMaterialType(); + existing.setId(1L); + existing.setMaterialCode("PLA"); + when(materialRepo.findByMaterialCode("PLA")).thenReturn(Optional.of(existing)); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createMaterial(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(materialRepo, never()).save(any(FilamentMaterialType.class)); + } + + @Test + void createVariant_withInvalidColorHex_shouldReturnBadRequest() { + FilamentMaterialType material = new FilamentMaterialType(); + material.setId(10L); + material.setMaterialCode("PLA"); + when(materialRepo.findById(10L)).thenReturn(Optional.of(material)); + when(variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, "Sunset Orange")) + .thenReturn(Optional.empty()); + + AdminUpsertFilamentVariantRequest payload = baseVariantPayload(); + payload.setColorHex("#12"); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createVariant(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(variantRepo, never()).save(any(FilamentVariant.class)); + } + + @Test + void createVariant_withValidPayload_shouldNormalizeDerivedFields() { + FilamentMaterialType material = new FilamentMaterialType(); + material.setId(10L); + material.setMaterialCode("PLA"); + when(materialRepo.findById(10L)).thenReturn(Optional.of(material)); + when(variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, "Sunset Orange")) + .thenReturn(Optional.empty()); + when(variantRepo.save(any(FilamentVariant.class))).thenAnswer(invocation -> { + FilamentVariant variant = invocation.getArgument(0); + variant.setId(42L); + return variant; + }); + + AdminUpsertFilamentVariantRequest payload = baseVariantPayload(); + payload.setFinishType("matte"); + payload.setIsMatte(false); + payload.setBrand(" Prusa "); + payload.setIsActive(null); + + AdminFilamentVariantDto dto = service.createVariant(payload); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FilamentVariant.class); + verify(variantRepo).save(captor.capture()); + FilamentVariant saved = captor.getValue(); + + assertEquals(42L, dto.getId()); + assertEquals("MATTE", saved.getFinishType()); + assertTrue(saved.getIsMatte()); + assertEquals("Prusa", saved.getBrand()); + assertTrue(saved.getIsActive()); + } + + @Test + void deleteVariant_whenInUse_shouldReturnConflict() { + Long variantId = 11L; + FilamentVariant variant = new FilamentVariant(); + variant.setId(variantId); + + when(variantRepo.findById(variantId)).thenReturn(Optional.of(variant)); + when(quoteLineItemRepo.existsByFilamentVariant_Id(variantId)).thenReturn(true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.deleteVariant(variantId) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + verify(variantRepo, never()).delete(any(FilamentVariant.class)); + } + + @Test + void getMaterials_shouldReturnAlphabeticalByCode() { + FilamentMaterialType abs = new FilamentMaterialType(); + abs.setId(2L); + abs.setMaterialCode("ABS"); + + FilamentMaterialType pla = new FilamentMaterialType(); + pla.setId(1L); + pla.setMaterialCode("PLA"); + + when(materialRepo.findAll()).thenReturn(List.of(pla, abs)); + + List result = service.getMaterials(); + + assertEquals(2, result.size()); + assertEquals("ABS", result.get(0).getMaterialCode()); + assertEquals("PLA", result.get(1).getMaterialCode()); + } + + private AdminUpsertFilamentVariantRequest baseVariantPayload() { + AdminUpsertFilamentVariantRequest payload = new AdminUpsertFilamentVariantRequest(); + payload.setMaterialTypeId(10L); + payload.setVariantDisplayName("Sunset Orange"); + payload.setColorName("Orange"); + payload.setColorHex("#FF8800"); + payload.setFinishType("GLOSSY"); + payload.setIsMatte(false); + payload.setIsSpecial(false); + payload.setCostChfPerKg(new BigDecimal("29.90")); + payload.setStockSpools(new BigDecimal("2.000")); + payload.setSpoolNetKg(new BigDecimal("1.000")); + payload.setIsActive(true); + return payload; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java new file mode 100644 index 0000000..f70ffd4 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java @@ -0,0 +1,211 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCadInvoiceCreateRequest; +import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.entity.PricingPolicy; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminOperationsControllerServiceTest { + + @Mock + private FilamentVariantStockKgRepository filamentStockRepo; + @Mock + private FilamentVariantRepository filamentVariantRepo; + @Mock + private CustomQuoteRequestRepository customQuoteRequestRepo; + @Mock + private CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private OrderRepository orderRepo; + @Mock + private PricingPolicyRepository pricingRepo; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + + @InjectMocks + private AdminOperationsControllerService service; + + @Test + void updateContactRequestStatus_withInvalidStatus_shouldReturnBadRequest() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + request.setStatus("PENDING"); + when(customQuoteRequestRepo.findById(requestId)).thenReturn(Optional.of(request)); + + AdminUpdateContactRequestStatusRequest payload = new AdminUpdateContactRequestStatusRequest(); + payload.setStatus("wrong"); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.updateContactRequestStatus(requestId, payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(customQuoteRequestRepo, never()).save(any(CustomQuoteRequest.class)); + } + + @Test + void updateContactRequestStatus_withValidStatus_shouldPersistAndReturnDetail() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + request.setStatus("PENDING"); + request.setCreatedAt(OffsetDateTime.now()); + request.setUpdatedAt(OffsetDateTime.now()); + + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); + attachment.setId(UUID.randomUUID()); + attachment.setOriginalFilename("drawing.stp"); + attachment.setMimeType("application/step"); + attachment.setFileSizeBytes(123L); + attachment.setCreatedAt(OffsetDateTime.now()); + + when(customQuoteRequestRepo.findById(requestId)).thenReturn(Optional.of(request)); + when(customQuoteRequestRepo.save(any(CustomQuoteRequest.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(customQuoteRequestAttachmentRepo.findByRequest_IdOrderByCreatedAtAsc(requestId)).thenReturn(List.of(attachment)); + + AdminUpdateContactRequestStatusRequest payload = new AdminUpdateContactRequestStatusRequest(); + payload.setStatus("done"); + + AdminContactRequestDetailDto dto = service.updateContactRequestStatus(requestId, payload); + + assertEquals("DONE", dto.getStatus()); + assertNotNull(dto.getUpdatedAt()); + assertEquals(1, dto.getAttachments().size()); + verify(customQuoteRequestRepo).save(request); + } + + @Test + void createOrUpdateCadInvoice_withMissingCadHours_shouldReturnBadRequest() { + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createOrUpdateCadInvoice(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + void createOrUpdateCadInvoice_withConvertedSession_shouldReturnConflict() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("CONVERTED"); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + payload.setSessionId(sessionId); + payload.setCadHours(new BigDecimal("1.0")); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createOrUpdateCadInvoice(payload) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + } + + @Test + void createOrUpdateCadInvoice_withNewSession_shouldUsePolicyCadRate() { + PricingPolicy policy = new PricingPolicy(); + policy.setCadCostChfPerHour(new BigDecimal("85")); + + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteSessionRepo.save(any(QuoteSession.class))).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + if (session.getId() == null) { + session.setId(UUID.randomUUID()); + } + return session; + }); + when(quoteLineItemRepo.findByQuoteSessionId(any(UUID.class))).thenReturn(List.of()); + when(quoteSessionTotalsService.compute(any(QuoteSession.class), anyList())) + .thenReturn(new QuoteSessionTotalsService.QuoteSessionTotals( + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("212.50"), + new BigDecimal("212.50"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("212.50"), + BigDecimal.ZERO + )); + + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + payload.setCadHours(new BigDecimal("2.5")); + payload.setCadHourlyRateChf(null); + payload.setNotes(" Custom CAD work "); + + AdminCadInvoiceDto dto = service.createOrUpdateCadInvoice(payload); + + assertEquals("CAD_ACTIVE", dto.getSessionStatus()); + assertEquals(new BigDecimal("2.50"), dto.getCadHours()); + assertEquals(new BigDecimal("85.00"), dto.getCadHourlyRateChf()); + assertEquals("Custom CAD work", dto.getNotes()); + assertEquals(new BigDecimal("212.50"), dto.getCadTotalChf()); + } + + @Test + void deleteQuoteSession_whenLinkedToOrder_shouldReturnConflict() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(orderRepo.existsBySourceQuoteSession_Id(sessionId)).thenReturn(true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.deleteQuoteSession(sessionId) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + verify(quoteSessionRepo, never()).delete(any(QuoteSession.class)); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java new file mode 100644 index 0000000..2622e96 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java @@ -0,0 +1,228 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.event.OrderShippedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminOrderControllerServiceTest { + + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private PaymentRepository paymentRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private PaymentService paymentService; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private AdminOrderControllerService service; + + @Test + void updatePaymentMethod_withBlankMethod_shouldReturnBadRequest() { + UUID orderId = UUID.randomUUID(); + when(orderRepo.findById(orderId)).thenReturn(Optional.of(buildOrder(orderId, "PENDING_PAYMENT"))); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.updatePaymentMethod(orderId, Map.of("method", " ")) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(paymentService, never()).updatePaymentMethod(any(), any()); + } + + @Test + void updatePaymentMethod_withValidMethod_shouldDelegateAndReturnUpdatedDto() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PENDING_PAYMENT"); + Payment payment = new Payment(); + payment.setMethod("BANK_TRANSFER"); + payment.setStatus("PENDING"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.of(payment)); + + OrderDto dto = service.updatePaymentMethod(orderId, Map.of("method", "BANK_TRANSFER")); + + assertEquals("BANK_TRANSFER", dto.getPaymentMethod()); + assertEquals("PENDING", dto.getPaymentStatus()); + verify(paymentService).updatePaymentMethod(orderId, "BANK_TRANSFER"); + } + + @Test + void updateOrderStatus_toShipped_shouldPublishOrderShippedEvent() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PAID"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("shipped"); + + OrderDto dto = service.updateOrderStatus(orderId, payload); + + assertEquals("SHIPPED", dto.getStatus()); + verify(eventPublisher).publishEvent(any(OrderShippedEvent.class)); + } + + @Test + void updateOrderStatus_fromShippedToShipped_shouldNotPublishEvent() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "SHIPPED"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("SHIPPED"); + + service.updateOrderStatus(orderId, payload); + + verify(eventPublisher, never()).publishEvent(any(OrderShippedEvent.class)); + } + + @Test + void downloadOrderItemFile_withInvalidRelativePath_shouldReturnNotFound() { + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = buildOrder(orderId, "PAID"); + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("../escape/path.stl"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.downloadOrderItemFile(orderId, orderItemId) + ); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + + @Test + void getOrder_shouldIncludePerItemPrintSettingsAndVariantMetadata() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PAID"); + + FilamentVariant variant = new FilamentVariant(); + variant.setId(42L); + variant.setVariantDisplayName("PLA Arancione Opaco"); + variant.setColorName("Arancione"); + variant.setColorHex("#ff7a00"); + + OrderItem item = new OrderItem(); + item.setId(UUID.randomUUID()); + item.setOrder(order); + item.setOriginalFilename("obj_4_Part 1.stl"); + item.setMaterialCode("PLA"); + item.setColorCode("Arancione"); + item.setFilamentVariant(variant); + item.setQuality("standard"); + item.setNozzleDiameterMm(new BigDecimal("0.60")); + item.setLayerHeightMm(new BigDecimal("0.24")); + item.setInfillPercent(15); + item.setInfillPattern("grid"); + item.setSupportsEnabled(Boolean.FALSE); + item.setQuantity(1); + item.setPrintTimeSeconds(2340); + item.setMaterialGrams(new BigDecimal("22.76")); + item.setUnitPriceChf(new BigDecimal("0.99")); + item.setLineTotalChf(new BigDecimal("0.99")); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of(item)); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + OrderDto dto = service.getOrder(orderId); + + assertEquals(1, dto.getItems().size()); + var itemDto = dto.getItems().get(0); + assertEquals(new BigDecimal("0.60"), itemDto.getNozzleDiameterMm()); + assertEquals(new BigDecimal("0.24"), itemDto.getLayerHeightMm()); + assertEquals(15, itemDto.getInfillPercent()); + assertEquals("grid", itemDto.getInfillPattern()); + assertEquals(Boolean.FALSE, itemDto.getSupportsEnabled()); + assertEquals(42L, itemDto.getFilamentVariantId()); + assertEquals("PLA Arancione Opaco", itemDto.getFilamentVariantDisplayName()); + assertEquals("Arancione", itemDto.getFilamentColorName()); + assertEquals("#ff7a00", itemDto.getFilamentColorHex()); + } + + private Order buildOrder(UUID orderId, String status) { + Order order = new Order(); + order.setId(orderId); + order.setStatus(status); + order.setCustomerEmail("customer@example.com"); + order.setCustomerPhone("+41910000000"); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Mario"); + order.setBillingLastName("Rossi"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setShippingSameAsBilling(true); + order.setCurrency("CHF"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSubtotalChf(BigDecimal.ZERO); + order.setCadTotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + return order; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java new file mode 100644 index 0000000..7cd15eb --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java @@ -0,0 +1,183 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.payment.TwintPaymentService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderControllerServiceTest { + + @Mock + private OrderService orderService; + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private TwintPaymentService twintPaymentService; + @Mock + private PaymentService paymentService; + @Mock + private PaymentRepository paymentRepo; + + @InjectMocks + private OrderControllerService service; + + @Test + void uploadOrderItemFile_withOrderMismatch_shouldReturnFalse() throws Exception { + UUID expectedOrderId = UUID.randomUUID(); + UUID wrongOrderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = new Order(); + order.setId(expectedOrderId); + + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("PENDING"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + MockMultipartFile file = new MockMultipartFile("file", "part.stl", "model/stl", "solid".getBytes()); + + boolean result = service.uploadOrderItemFile(wrongOrderId, orderItemId, file); + + assertFalse(result); + verify(storageService, never()).store(any(MockMultipartFile.class), any(Path.class)); + verify(orderItemRepo, never()).save(any(OrderItem.class)); + } + + @Test + void uploadOrderItemFile_withPendingPath_shouldStoreAndPersistMetadata() throws Exception { + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = new Order(); + order.setId(orderId); + + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("PENDING"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + MockMultipartFile file = new MockMultipartFile("file", "model.STL", "model/stl", "mesh".getBytes()); + + boolean result = service.uploadOrderItemFile(orderId, orderItemId, file); + + assertTrue(result); + + ArgumentCaptor pathCaptor = ArgumentCaptor.forClass(Path.class); + verify(storageService).store(eq(file), pathCaptor.capture()); + Path storedPath = pathCaptor.getValue(); + assertTrue(storedPath.startsWith(Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()))); + + assertTrue(item.getStoredFilename().endsWith(".stl")); + assertEquals(file.getSize(), item.getFileSizeBytes()); + assertEquals("model/stl", item.getMimeType()); + verify(orderItemRepo).save(item); + } + + @Test + void getOrder_withShippedStatus_shouldRedactPersonalData() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "SHIPPED"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + Optional result = service.getOrder(orderId); + + assertTrue(result.isPresent()); + OrderDto dto = result.get(); + assertNull(dto.getCustomerEmail()); + assertNull(dto.getCustomerPhone()); + assertNull(dto.getBillingAddress()); + assertNull(dto.getShippingAddress()); + } + + @Test + void getTwintQr_withOversizedInput_shouldClampSizeTo600() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PENDING_PAYMENT"); + + byte[] png = new byte[]{1, 2, 3}; + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(twintPaymentService.generateQrPng(order, 600)).thenReturn(png); + + ResponseEntity response = service.getTwintQr(orderId, 5000); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType()); + assertArrayEquals(png, response.getBody()); + verify(twintPaymentService).generateQrPng(order, 600); + } + + private Order buildOrder(UUID orderId, String status) { + Order order = new Order(); + order.setId(orderId); + order.setStatus(status); + order.setCustomerEmail("customer@example.com"); + order.setCustomerPhone("+41910000000"); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Mario"); + order.setBillingLastName("Rossi"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setShippingSameAsBilling(true); + order.setCurrency("CHF"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSubtotalChf(BigDecimal.ZERO); + order.setCadTotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + return order; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java new file mode 100644 index 0000000..b14f688 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java @@ -0,0 +1,74 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ContactRequestLocalizationServiceTest { + + private ContactRequestLocalizationService service; + + @BeforeEach + void setUp() { + service = new ContactRequestLocalizationService(); + } + + @Test + void normalizeLanguage_shouldMapKnownPrefixes() { + assertEquals("de", service.normalizeLanguage("de-CH")); + assertEquals("en", service.normalizeLanguage("EN")); + assertEquals("fr", service.normalizeLanguage("fr_CA")); + assertEquals("it", service.normalizeLanguage("")); + } + + @Test + void resolveRecipientName_shouldUsePriorityAndFallback() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setName("Mario Rossi"); + assertEquals("Mario Rossi", service.resolveRecipientName(request, "it")); + + request.setName(" "); + request.setContactPerson("Laura Bianchi"); + assertEquals("Laura Bianchi", service.resolveRecipientName(request, "it")); + + request.setContactPerson(" "); + request.setCompanyName("3D Fab SA"); + assertEquals("3D Fab SA", service.resolveRecipientName(request, "it")); + + request.setCompanyName(" "); + assertEquals("customer", service.resolveRecipientName(request, "en")); + } + + @Test + void applyCustomerContactRequestTexts_shouldPopulateLocalizedLabels() { + Map templateData = new HashMap<>(); + templateData.put("recipientName", "Mario"); + UUID requestId = UUID.randomUUID(); + + String subject = service.applyCustomerContactRequestTexts(templateData, "fr", requestId); + + assertEquals("Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab", subject); + assertEquals("Date", templateData.get("labelDate")); + assertEquals("Bonjour Mario,", templateData.get("greetingText")); + } + + @Test + void localizeRequestType_andCustomerType_shouldReturnExpectedValues() { + assertEquals("Custom part request", service.localizeRequestType("print_service", "en")); + assertEquals("Azienda", service.localizeCustomerType("business", "it")); + assertEquals("-", service.localizeCustomerType(null, "de")); + } + + @Test + void localeForLanguage_shouldReturnExpectedLocale() { + assertEquals(Locale.GERMAN, service.localeForLanguage("de")); + assertEquals(Locale.ITALIAN, service.localeForLanguage("unknown")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java new file mode 100644 index 0000000..007f5f8 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java @@ -0,0 +1,163 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.service.storage.ClamAVService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestAttachmentServiceTest { + + @Mock + private CustomQuoteRequestAttachmentRepository attachmentRepo; + @Mock + private ClamAVService clamAVService; + + @InjectMocks + private CustomQuoteRequestAttachmentService service; + + private UUID lastRequestIdForCleanup; + + @AfterEach + void cleanStorageDirectory() { + if (lastRequestIdForCleanup == null) { + return; + } + Path requestDir = Paths.get("storage_requests", "quote-requests", lastRequestIdForCleanup.toString()); + if (!Files.exists(requestDir)) { + return; + } + try (var walk = Files.walk(requestDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + void storeAttachments_withNullFiles_shouldReturnZero() throws Exception { + CustomQuoteRequest request = buildRequest(); + + int count = service.storeAttachments(request, null); + + assertEquals(0, count); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withTooManyFiles_shouldThrowIOException() { + CustomQuoteRequest request = buildRequest(); + List files = new ArrayList<>(); + for (int i = 0; i < 16; i++) { + files.add(new MockMultipartFile("files", "file-" + i + ".stl", "model/stl", "solid".getBytes(StandardCharsets.UTF_8))); + } + + IOException ex = assertThrows( + IOException.class, + () -> service.storeAttachments(request, new ArrayList<>(files)) + ); + + assertTrue(ex.getMessage().contains("Too many files")); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withCompressedFile_shouldThrowBadRequest() { + CustomQuoteRequest request = buildRequest(); + MockMultipartFile file = new MockMultipartFile( + "files", + "archive.zip", + "application/zip", + "dummy".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.storeAttachments(request, List.of(file)) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withValidFile_shouldScanPersistAndWriteOnDisk() throws Exception { + CustomQuoteRequest request = buildRequest(); + lastRequestIdForCleanup = request.getId(); + + MockMultipartFile file = new MockMultipartFile( + "files", + "part.stl", + "model/stl", + "solid model".getBytes(StandardCharsets.UTF_8) + ); + + when(clamAVService.scan(any())).thenReturn(true); + when(attachmentRepo.save(any(CustomQuoteRequestAttachment.class))).thenAnswer(invocation -> { + CustomQuoteRequestAttachment attachment = invocation.getArgument(0); + if (attachment.getId() == null) { + attachment.setId(UUID.randomUUID()); + } + return attachment; + }); + + int savedCount = service.storeAttachments(request, List.of(file)); + + assertEquals(1, savedCount); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CustomQuoteRequestAttachment.class); + verify(attachmentRepo, times(2)).save(captor.capture()); + verify(clamAVService, times(1)).scan(any()); + + CustomQuoteRequestAttachment persisted = captor.getAllValues().get(1); + Path absolutePath = Paths.get("storage_requests").toAbsolutePath().normalize() + .resolve(persisted.getStoredRelativePath()) + .normalize(); + + assertTrue(Files.exists(absolutePath)); + assertEquals("solid model", Files.readString(absolutePath, StandardCharsets.UTF_8)); + } + + private CustomQuoteRequest buildRequest() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(UUID.randomUUID()); + return request; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java new file mode 100644 index 0000000..e1a267a --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java @@ -0,0 +1,110 @@ +package com.printcalculator.service.request; + +import com.printcalculator.dto.QuoteRequestDto; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestControllerServiceTest { + + @Mock + private CustomQuoteRequestRepository requestRepo; + @Mock + private CustomQuoteRequestAttachmentService attachmentService; + @Mock + private CustomQuoteRequestNotificationService notificationService; + + @InjectMocks + private CustomQuoteRequestControllerService service; + + @Test + void createCustomQuoteRequest_withMissingConsents_shouldThrowBadRequest() throws Exception { + QuoteRequestDto dto = buildRequest(false, true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createCustomQuoteRequest(dto, List.of()) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(requestRepo, attachmentService, notificationService); + } + + @Test + void createCustomQuoteRequest_withValidPayload_shouldPersistAndDelegate() throws Exception { + UUID requestId = UUID.randomUUID(); + QuoteRequestDto dto = buildRequest(true, true); + List files = List.of(); + + when(requestRepo.save(any(CustomQuoteRequest.class))).thenAnswer(invocation -> { + CustomQuoteRequest request = invocation.getArgument(0); + request.setId(requestId); + return request; + }); + when(attachmentService.storeAttachments(any(CustomQuoteRequest.class), eq(files))).thenReturn(2); + + CustomQuoteRequest saved = service.createCustomQuoteRequest(dto, files); + + assertNotNull(saved); + assertEquals(requestId, saved.getId()); + assertEquals("PENDING", saved.getStatus()); + assertNotNull(saved.getCreatedAt()); + assertNotNull(saved.getUpdatedAt()); + + verify(requestRepo).save(any(CustomQuoteRequest.class)); + verify(attachmentService).storeAttachments(saved, files); + verify(notificationService).sendNotifications(saved, 2, "de-CH"); + } + + @Test + void getCustomQuoteRequest_shouldDelegateToRepository() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + when(requestRepo.findById(requestId)).thenReturn(Optional.of(request)); + + Optional result = service.getCustomQuoteRequest(requestId); + + assertEquals(Optional.of(request), result); + verify(requestRepo).findById(requestId); + } + + private QuoteRequestDto buildRequest(boolean acceptTerms, boolean acceptPrivacy) { + QuoteRequestDto dto = new QuoteRequestDto(); + dto.setRequestType("PRINT_SERVICE"); + dto.setCustomerType("PRIVATE"); + dto.setLanguage("de-CH"); + dto.setEmail("customer@example.com"); + dto.setPhone("+41910000000"); + dto.setName("Mario Rossi"); + dto.setCompanyName("3D Fab SA"); + dto.setContactPerson("Mario Rossi"); + dto.setMessage("Vorrei una quotazione."); + dto.setAcceptTerms(acceptTerms); + dto.setAcceptPrivacy(acceptPrivacy); + return dto; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java new file mode 100644 index 0000000..121faee --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.service.email.EmailNotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestNotificationServiceTest { + + @Mock + private EmailNotificationService emailNotificationService; + + private ContactRequestLocalizationService localizationService; + private CustomQuoteRequestNotificationService service; + + @BeforeEach + void setUp() { + localizationService = new ContactRequestLocalizationService(); + service = new CustomQuoteRequestNotificationService(emailNotificationService, localizationService); + } + + @Test + void sendNotifications_withAdminAndCustomerEnabled_shouldSendBothEmails() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", "admin@3d-fab.ch"); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", true); + + CustomQuoteRequest request = buildRequest(); + + service.sendNotifications(request, 3, "en-US"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> dataCaptor = (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Map.class); + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor templateCaptor = ArgumentCaptor.forClass(String.class); + + verify(emailNotificationService, times(2)).sendEmail( + toCaptor.capture(), + subjectCaptor.capture(), + templateCaptor.capture(), + dataCaptor.capture() + ); + + List recipients = toCaptor.getAllValues(); + assertTrue(recipients.contains("admin@3d-fab.ch")); + assertTrue(recipients.contains("customer@example.com")); + + int customerIndex = recipients.indexOf("customer@example.com"); + assertEquals("contact-request-customer", templateCaptor.getAllValues().get(customerIndex)); + assertEquals("We received your contact request #" + request.getId() + " - 3D-Fab", subjectCaptor.getAllValues().get(customerIndex)); + assertEquals("Date", dataCaptor.getAllValues().get(customerIndex).get("labelDate")); + + int adminIndex = recipients.indexOf("admin@3d-fab.ch"); + assertEquals("contact-request-admin", templateCaptor.getAllValues().get(adminIndex)); + assertEquals(3, dataCaptor.getAllValues().get(adminIndex).get("attachmentsCount")); + } + + @Test + void sendNotifications_withCustomerDisabled_shouldOnlySendAdminEmail() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", "admin@3d-fab.ch"); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", false); + + service.sendNotifications(buildRequest(), 1, "it"); + + verify(emailNotificationService, times(1)).sendEmail( + org.mockito.ArgumentMatchers.eq("admin@3d-fab.ch"), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("contact-request-admin"), + org.mockito.ArgumentMatchers.anyMap() + ); + } + + @Test + void sendNotifications_withMissingAdminAddressAndCustomerDisabled_shouldSendNothing() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", " "); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", false); + + service.sendNotifications(buildRequest(), 1, "fr"); + + verify(emailNotificationService, never()).sendEmail( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyMap() + ); + } + + private CustomQuoteRequest buildRequest() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(UUID.randomUUID()); + request.setRequestType("PRINT_SERVICE"); + request.setCustomerType("PRIVATE"); + request.setName("Mario Rossi"); + request.setCompanyName("3D Fab SA"); + request.setContactPerson("Mario Rossi"); + request.setEmail("customer@example.com"); + request.setPhone("+41910000000"); + request.setMessage("Vorrei una quotazione."); + request.setCreatedAt(OffsetDateTime.parse("2026-03-05T10:15:30+01:00")); + return request; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 521def9..335ecad 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -193,17 +193,22 @@

Qta: {{ item.quantity }} | Materiale: - {{ item.materialCode || "-" }} | Colore: + {{ getItemMaterialLabel(item) }} | Colore: - {{ item.colorCode || "-" }} + + {{ getItemColorLabel(item) }} + + ({{ colorCode }}) + + | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: {{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.infillPercent ?? "-" }}% | Supporti: - {{ item.supportsEnabled ? "Sì" : "No" }} + {{ formatSupports(item.supportsEnabled) }} | Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

@@ -283,10 +288,11 @@
{{ item.originalFilename }} - {{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm + {{ getItemMaterialLabel(item) }} | Colore: + {{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} | - {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} + {{ formatSupportsState(item.supportsEnabled) }}
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 74dbd4c..836425e 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -3,6 +3,7 @@ import { Component, inject, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AdminOrder, + AdminOrderItem, AdminOrdersService, } from '../services/admin-orders.service'; import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; @@ -273,6 +274,68 @@ export class AdminDashboardComponent implements OnInit { ); } + getItemMaterialLabel(item: AdminOrderItem): string { + const variantName = (item.filamentVariantDisplayName || '').trim(); + const materialCode = (item.materialCode || '').trim(); + if (!variantName) { + return materialCode || '-'; + } + if (!materialCode) { + return variantName; + } + const normalizedVariant = variantName.toLowerCase(); + const normalizedCode = materialCode.toLowerCase(); + return normalizedVariant.includes(normalizedCode) + ? variantName + : `${variantName} (${materialCode})`; + } + + getItemColorLabel(item: AdminOrderItem): string { + const colorName = (item.filamentColorName || '').trim(); + const colorCode = (item.colorCode || '').trim(); + return colorName || colorCode || '-'; + } + + getItemColorHex(item: AdminOrderItem): string | null { + const variantHex = (item.filamentColorHex || '').trim(); + if (this.isHexColor(variantHex)) { + return variantHex; + } + const code = (item.colorCode || '').trim(); + if (this.isHexColor(code)) { + return code; + } + return null; + } + + getItemColorCodeSuffix(item: AdminOrderItem): string | null { + const colorHex = this.getItemColorHex(item); + if (!colorHex) { + return null; + } + return colorHex === this.getItemColorLabel(item) ? null : colorHex; + } + + formatSupports(value?: boolean): string { + if (value === true) { + return 'Sì'; + } + if (value === false) { + return 'No'; + } + return '-'; + } + + formatSupportsState(value?: boolean): string { + if (value === true) { + return 'Supporti ON'; + } + if (value === false) { + return 'Supporti OFF'; + } + return 'Supporti -'; + } + isSelected(orderId: string): boolean { return this.selectedOrder?.id === orderId; } diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 395010c..0ac32c4 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -8,6 +8,10 @@ export interface AdminOrderItem { originalFilename: string; materialCode: string; colorCode: string; + filamentVariantId?: number; + filamentVariantDisplayName?: string; + filamentColorName?: string; + filamentColorHex?: string; quality?: string; nozzleDiameterMm?: number; layerHeightMm?: number; diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 5c3b16f..ac81350 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -5,11 +5,11 @@
- {{ totals().price | currency: result().currency }} + {{ costBreakdown().subtotal | currency: result().currency }} @@ -22,18 +22,6 @@
- {{ - "CALC.SETUP_NOTE" - | translate - : { cost: (result().setupCost | currency: result().currency) } - }}
- @if ((result().cadTotal || 0) > 0) { - - Servizio CAD: {{ result().cadTotal | currency: result().currency }} - -
- } {{ "CALC.SHIPPING_NOTE" | translate }} @@ -63,7 +51,7 @@ {{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitWeight | number: "1.0-0" }}g | - {{ item.material || "N/D" }} + {{ item.material || "N/D" }} @if (getItemDifferenceLabel(item.fileName, item.material)) { | @@ -108,6 +96,25 @@ }
+
+
+ Costo di Avvio + {{ costBreakdown().baseSetup | currency: result().currency }} +
+ @if (costBreakdown().nozzleChange > 0) { +
+ Cambio Ugello + {{ + costBreakdown().nozzleChange | currency: result().currency + }} +
+ } +
+ Totale + {{ costBreakdown().total | currency: result().currency }} +
+
+
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 2ed738c..6ab0848 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -55,19 +55,6 @@ color: var(--color-text-muted); } -.material-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid #d9d4bd; - background: #fbf7e9; - color: #6d5b1d; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.2px; -} - .item-controls { display: flex; align-items: center; @@ -218,3 +205,35 @@ color: #6f5b1a; font-size: 0.9rem; } + +.cost-breakdown { + margin-top: var(--space-2); + margin-bottom: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); +} + +.cost-row, +.cost-total { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); +} + +.cost-row { + color: var(--color-text); + font-size: 0.95rem; + margin-bottom: var(--space-2); +} + +.cost-total { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 2px solid var(--color-border); + font-size: 1.2rem; + font-weight: 700; + color: var(--color-text); +} diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts index 5784b3a..e187cad 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TranslateModule } from '@ngx-translate/core'; import { QuoteResultComponent } from './quote-result.component'; import { QuoteResult } from '../../services/quote-estimator.service'; @@ -38,7 +39,11 @@ describe('QuoteResultComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [QuoteResultComponent, TranslateModule.forRoot()], + imports: [ + QuoteResultComponent, + TranslateModule.forRoot(), + HttpClientTestingModule, + ], }).compileComponents(); fixture = TestBed.createComponent(QuoteResultComponent); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 3e1c30c..939b5ad 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -134,17 +134,37 @@ export class QuoteResultComponent implements OnDestroy { this.items().some((item) => item.quantity > this.directOrderLimit), ); - totals = computed(() => { + costBreakdown = computed(() => { const currentItems = this.items(); - const setup = this.result().setupCost; const cad = this.result().cadTotal || 0; - let price = setup + cad; + let subtotal = cad; + currentItems.forEach((item) => { + subtotal += item.unitPrice * item.quantity; + }); + + const nozzleChange = Math.max(0, this.result().nozzleChangeCost || 0); + const baseSetupRaw = + this.result().baseSetupCost != null + ? this.result().baseSetupCost + : this.result().setupCost - nozzleChange; + const baseSetup = Math.max(0, baseSetupRaw || 0); + const total = subtotal + baseSetup + nozzleChange; + + return { + subtotal: Math.round(subtotal * 100) / 100, + baseSetup: Math.round(baseSetup * 100) / 100, + nozzleChange: Math.round(nozzleChange * 100) / 100, + total: Math.round(total * 100) / 100, + }; + }); + + totals = computed(() => { + const currentItems = this.items(); let time = 0; let weight = 0; currentItems.forEach((i) => { - price += i.unitPrice * i.quantity; time += i.unitTime * i.quantity; weight += i.unitWeight * i.quantity; }); @@ -153,7 +173,7 @@ export class QuoteResultComponent implements OnDestroy { const minutes = Math.ceil((time % 3600) / 60); return { - price: Math.round(price * 100) / 100, + price: this.costBreakdown().total, hours, minutes, weight: Math.ceil(weight), diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 25ab039..d2e9417 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -107,7 +107,27 @@ >.

- @if (mode() === "advanced") { + @if (mode() === "easy") { +
+ + + +
+ } @else {
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | @@ -328,7 +330,14 @@
{{ "CHECKOUT.SETUP_FEE" | translate }} - {{ session.session.setupCostChf | currency: "CHF" }} + {{ + (session.baseSetupCostChf ?? session.session.setupCostChf) + | currency: "CHF" + }} +
+
+ Cambio Ugello + {{ session.nozzleChangeCostChf | currency: "CHF" }}
{{ "CHECKOUT.SHIPPING" | translate }} diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index 081022b..d757b5f 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -230,12 +230,24 @@ app-toggle-selector.user-type-selector-compact { font-size: 0.85rem; color: var(--color-text-muted); + .item-color { + display: inline-flex; + align-items: center; + gap: 6px; + } + .color-dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; border: 1px solid var(--color-border); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + } + + .color-name { + font-weight: 500; + color: var(--color-text-muted); } } diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 66e98fc..72ab9a3 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -18,6 +18,7 @@ import { } from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; import { LanguageService } from '../../core/services/language.service'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; +import { getColorHex } from '../../core/constants/colors.const'; @Component({ selector: 'app-checkout', @@ -55,6 +56,8 @@ export class CheckoutComponent implements OnInit { selectedPreviewFile = signal(null); selectedPreviewName = signal(''); selectedPreviewColor = signal('#c9ced6'); + private variantHexById = new Map(); + private variantHexByColorName = new Map(); userTypeOptions: ToggleOption[] = [ { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, @@ -128,6 +131,8 @@ export class CheckoutComponent implements OnInit { } ngOnInit(): void { + this.loadMaterialColorPalette(); + this.route.queryParams.subscribe((params) => { this.sessionId = params['session']; if (!this.sessionId) { @@ -212,8 +217,40 @@ export class CheckoutComponent implements OnInit { } previewColor(item: any): string { + return this.itemColorSwatch(item); + } + + itemColorLabel(item: any): string { const raw = String(item?.colorCode ?? '').trim(); - return raw || '#c9ced6'; + return raw || '-'; + } + + itemColorSwatch(item: any): string { + const variantId = Number(item?.filamentVariantId); + if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) { + return this.variantHexById.get(variantId)!; + } + + const raw = String(item?.colorCode ?? '').trim(); + if (!raw) { + return '#c9ced6'; + } + + if (this.isHexColor(raw)) { + return raw; + } + + const byName = this.variantHexByColorName.get(raw.toLowerCase()); + if (byName) { + return byName; + } + + const fallback = getColorHex(raw); + if (fallback && fallback !== '#facf0a') { + return fallback; + } + + return '#c9ced6'; } isPreviewLoading(item: any): boolean { @@ -250,6 +287,41 @@ export class CheckoutComponent implements OnInit { this.selectedPreviewColor.set('#c9ced6'); } + private loadMaterialColorPalette(): void { + this.quoteService.getOptions().subscribe({ + next: (options) => { + this.variantHexById.clear(); + this.variantHexByColorName.clear(); + + for (const material of options?.materials || []) { + for (const variant of material?.variants || []) { + const variantId = Number(variant?.id); + const colorHex = String(variant?.hexColor || '').trim(); + const colorName = String(variant?.colorName || '').trim(); + + if (Number.isFinite(variantId) && colorHex) { + this.variantHexById.set(variantId, colorHex); + } + if (colorName && colorHex) { + this.variantHexByColorName.set(colorName.toLowerCase(), colorHex); + } + } + } + }, + error: () => { + this.variantHexById.clear(); + this.variantHexByColorName.clear(); + }, + }); + } + + private isHexColor(value?: string): boolean { + return ( + typeof value === 'string' && + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) + ); + } + private loadStlPreviews(session: any): void { if ( !this.sessionId ||