dev #29

Merged
JoeKung merged 30 commits from dev into main 2026-03-09 09:58:45 +01:00
41 changed files with 3503 additions and 1280 deletions
Showing only changes of commit 235fe7780d - Show all commits

View File

@@ -2,519 +2,46 @@ package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto; import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.service.request.CustomQuoteRequestControllerService;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.storage.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid; 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.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException; 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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException; 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.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class); private final CustomQuoteRequestControllerService customQuoteRequestControllerService;
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}") public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) {
private boolean contactRequestAdminMailEnabled; this.customQuoteRequestControllerService = customQuoteRequestControllerService;
@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<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> 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;
} }
// 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest( public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@Valid @RequestPart("request") QuoteRequestDto requestDto, @Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files @RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException { ) throws IOException {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files));
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);
} }
// 2. Get Request
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) { public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) {
return requestRepo.findById(id) return customQuoteRequestControllerService.getCustomQuoteRequest(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .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<String, Object> 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<String, Object> 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<String, Object> 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;
}
} }

View File

@@ -12,8 +12,10 @@ import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.ProfileManager;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -22,8 +24,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Comparator; import java.util.Comparator;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.LinkedHashSet;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -34,23 +39,29 @@ public class OptionsController {
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo; private final PrinterMachineRepository printerMachineRepo;
private final PrinterMachineProfileRepository printerMachineProfileRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver; private final OrcaProfileResolver orcaProfileResolver;
private final ProfileManager profileManager;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
NozzleOptionRepository nozzleRepo, NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo, PrinterMachineRepository printerMachineRepo,
PrinterMachineProfileRepository printerMachineProfileRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver, OrcaProfileResolver orcaProfileResolver,
ProfileManager profileManager,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo; this.printerMachineRepo = printerMachineRepo;
this.printerMachineProfileRepo = printerMachineProfileRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo; this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver; this.orcaProfileResolver = orcaProfileResolver;
this.profileManager = profileManager;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@@ -116,8 +127,27 @@ public class OptionsController {
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
PrinterMachine targetMachine = resolveMachine(printerMachineId);
Set<BigDecimal> 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<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(n.getIsActive())) .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)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO( .map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(), n.getNozzleDiameterMm().doubleValue(),
@@ -129,16 +159,62 @@ public class OptionsController {
.toList(); .toList();
Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle();
Set<BigDecimal> visibleNozzlesFromOptions = nozzles.stream()
.map(OptionsResponse.NozzleOptionDTO::value)
.map(BigDecimal::valueOf)
.map(nozzleLayerHeightPolicyService::normalizeNozzle)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<BigDecimal, List<BigDecimal>> effectiveRulesByNozzle = new LinkedHashMap<>();
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
List<BigDecimal> compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle);
List<BigDecimal> effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers);
if (!effective.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, effective);
}
}
if (effectiveRulesByNozzle.isEmpty()) {
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
if (!policyLayers.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, policyLayers);
}
}
}
Set<BigDecimal> 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( BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
); );
if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) {
List<OptionsResponse.LayerHeightOptionDTO> layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of())); selectedNozzle = visibleNozzles.iterator().next();
if (layers.isEmpty()) {
layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
} }
List<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = rulesByNozzle.entrySet().stream() List<OptionsResponse.LayerHeightOptionDTO> 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<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream()
.map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO( .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
entry.getKey().doubleValue(), entry.getKey().doubleValue(),
toLayerDtos(entry.getValue()) toLayerDtos(entry.getValue())
@@ -156,13 +232,7 @@ public class OptionsController {
} }
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
PrinterMachine machine = null; PrinterMachine machine = resolveMachine(printerMachineId);
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
if (machine == null) { if (machine == null) {
return Set.of(); return Set.of();
} }
@@ -187,6 +257,17 @@ public class OptionsController {
.collect(Collectors.toSet()); .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<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) { private List<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) {
return layers.stream() return layers.stream()
.sorted(Comparator.naturalOrder()) .sorted(Comparator.naturalOrder())
@@ -197,6 +278,52 @@ public class OptionsController {
.toList(); .toList();
} }
private List<BigDecimal> 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<BigDecimal> mergePolicyAndProcessLayers(List<BigDecimal> policyLayers,
List<BigDecimal> 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<BigDecimal> allowedByPolicy = policyLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<BigDecimal> 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) { private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex(); return variant.getColorHex();

View File

@@ -4,77 +4,39 @@ import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto; import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.service.admin.AdminFilamentControllerService;
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.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
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.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.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 @RestController
@RequestMapping("/api/admin/filaments") @RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminFilamentController { public class AdminFilamentController {
private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999");
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final Set<String> ALLOWED_FINISH_TYPES = Set.of(
"GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL"
);
private final FilamentMaterialTypeRepository materialRepo; private final AdminFilamentControllerService adminFilamentControllerService;
private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentController( public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) {
FilamentMaterialTypeRepository materialRepo, this.adminFilamentControllerService = adminFilamentControllerService;
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo
) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
} }
@GetMapping("/materials") @GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() { public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
List<AdminFilamentMaterialTypeDto> response = materialRepo.findAll().stream() return ResponseEntity.ok(adminFilamentControllerService.getMaterials());
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/variants") @GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() { public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
List<AdminFilamentVariantDto> response = variantRepo.findAll().stream() return ResponseEntity.ok(adminFilamentControllerService.getVariants());
.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);
} }
@PostMapping("/materials") @PostMapping("/materials")
@@ -82,13 +44,7 @@ public class AdminFilamentController {
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial( public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload @RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) { ) {
String materialCode = normalizeAndValidateMaterialCode(payload); return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload));
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return ResponseEntity.ok(toMaterialDto(saved));
} }
@PutMapping("/materials/{materialTypeId}") @PutMapping("/materials/{materialTypeId}")
@@ -97,15 +53,7 @@ public class AdminFilamentController {
@PathVariable Long materialTypeId, @PathVariable Long materialTypeId,
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload @RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) { ) {
FilamentMaterialType material = materialRepo.findById(materialTypeId) return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload));
.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));
} }
@PostMapping("/variants") @PostMapping("/variants")
@@ -113,17 +61,7 @@ public class AdminFilamentController {
public ResponseEntity<AdminFilamentVariantDto> createVariant( public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody AdminUpsertFilamentVariantRequest payload @RequestBody AdminUpsertFilamentVariantRequest payload
) { ) {
FilamentMaterialType material = validateAndResolveMaterial(payload); return ResponseEntity.ok(adminFilamentControllerService.createVariant(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));
} }
@PutMapping("/variants/{variantId}") @PutMapping("/variants/{variantId}")
@@ -132,224 +70,13 @@ public class AdminFilamentController {
@PathVariable Long variantId, @PathVariable Long variantId,
@RequestBody AdminUpsertFilamentVariantRequest payload @RequestBody AdminUpsertFilamentVariantRequest payload
) { ) {
FilamentVariant variant = variantRepo.findById(variantId) return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload));
.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));
} }
@DeleteMapping("/variants/{variantId}") @DeleteMapping("/variants/{variantId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) { public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) {
FilamentVariant variant = variantRepo.findById(variantId) adminFilamentControllerService.deleteVariant(variantId);
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) {
throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted");
}
variantRepo.delete(variant);
return ResponseEntity.noContent().build(); 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;
}
} }

View File

@@ -1,37 +1,14 @@
package com.printcalculator.controller.admin; 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.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto; import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.service.admin.AdminOperationsControllerService;
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.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.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID; 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 @RestController
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOperationsController { 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<String> CONTACT_REQUEST_ALLOWED_STATUSES = Set.of(
"NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED"
);
private final FilamentVariantStockKgRepository filamentStockRepo; private final AdminOperationsControllerService adminOperationsControllerService;
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 AdminOperationsController( public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) {
FilamentVariantStockKgRepository filamentStockRepo, this.adminOperationsControllerService = adminOperationsControllerService;
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;
} }
@GetMapping("/filament-stock") @GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() { public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock());
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> variantsById;
if (variantIds.isEmpty()) {
variantsById = Collections.emptyMap();
} else {
variantsById = filamentVariantRepo.findAllById(variantIds).stream()
.collect(Collectors.toMap(FilamentVariant::getId, variant -> variant));
}
List<AdminFilamentStockDto> 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);
} }
@GetMapping("/contact-requests") @GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() { public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
List<AdminContactRequestDto> response = customQuoteRequestRepo.findAll( return ResponseEntity.ok(adminOperationsControllerService.getContactRequests());
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toContactRequestDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/contact-requests/{requestId}") @GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) { public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId));
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return ResponseEntity.ok(toContactRequestDetailDto(request, attachments));
} }
@PatchMapping("/contact-requests/{requestId}/status") @PatchMapping("/contact-requests/{requestId}/status")
@@ -192,31 +55,7 @@ public class AdminOperationsController {
@PathVariable UUID requestId, @PathVariable UUID requestId,
@RequestBody AdminUpdateContactRequestStatusRequest payload @RequestBody AdminUpdateContactRequestStatusRequest payload
) { ) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload));
.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<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments));
} }
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
@@ -224,87 +63,17 @@ public class AdminOperationsController {
@PathVariable UUID requestId, @PathVariable UUID requestId,
@PathVariable UUID attachmentId @PathVariable UUID attachmentId
) { ) {
CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) return adminOperationsControllerService.downloadContactRequestAttachment(requestId, 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");
}
} }
@GetMapping("/sessions") @GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() { public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll( return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions());
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toQuoteSessionDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/cad-invoices") @GetMapping("/cad-invoices")
public ResponseEntity<List<AdminCadInvoiceDto>> getCadInvoices() { public ResponseEntity<List<AdminCadInvoiceDto>> getCadInvoices() {
List<AdminCadInvoiceDto> response = quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) return ResponseEntity.ok(adminOperationsControllerService.getCadInvoices());
.stream()
.filter(this::isCadSessionRecord)
.map(this::toCadInvoiceDto)
.toList();
return ResponseEntity.ok(response);
} }
@PostMapping("/cad-invoices") @PostMapping("/cad-invoices")
@@ -312,198 +81,13 @@ public class AdminOperationsController {
public ResponseEntity<AdminCadInvoiceDto> createOrUpdateCadInvoice( public ResponseEntity<AdminCadInvoiceDto> createOrUpdateCadInvoice(
@RequestBody AdminCadInvoiceCreateRequest payload @RequestBody AdminCadInvoiceCreateRequest payload
) { ) {
if (payload == null || payload.getCadHours() == null) { return ResponseEntity.ok(adminOperationsControllerService.createOrUpdateCadInvoice(payload));
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));
} }
@DeleteMapping("/sessions/{sessionId}") @DeleteMapping("/sessions/{sessionId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) { public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) {
QuoteSession session = quoteSessionRepo.findById(sessionId) adminOperationsControllerService.deleteQuoteSession(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);
return ResponseEntity.noContent().build(); 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<AdminContactRequestAttachmentDto> 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<QuoteLineItem> 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<Path> 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");
}
}
} }

View File

@@ -8,6 +8,10 @@ public class OrderItemDto {
private String originalFilename; private String originalFilename;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private Long filamentVariantId;
private String filamentVariantDisplayName;
private String filamentColorName;
private String filamentColorHex;
private String quality; private String quality;
private BigDecimal nozzleDiameterMm; private BigDecimal nozzleDiameterMm;
private BigDecimal layerHeightMm; private BigDecimal layerHeightMm;
@@ -33,6 +37,18 @@ public class OrderItemDto {
public String getColorCode() { return colorCode; } public String getColorCode() { return colorCode; }
public void setColorCode(String colorCode) { this.colorCode = 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 String getQuality() { return quality; }
public void setQuality(String quality) { this.quality = quality; } public void setQuality(String quality) { this.quality = quality; }

View File

@@ -3,5 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.Optional;
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> { public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
Optional<NozzleOption> findFirstByNozzleDiameterMmAndIsActiveTrue(BigDecimal nozzleDiameterMm);
} }

View File

@@ -157,7 +157,7 @@ public class OrderService {
order.setSubtotalChf(BigDecimal.ZERO); order.setSubtotalChf(BigDecimal.ZERO);
order.setTotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); order.setSetupCostChf(totals.setupCostChf());
order.setShippingCostChf(totals.shippingCostChf()); order.setShippingCostChf(totals.shippingCostChf());
order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus())); order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus()));
order.setSourceRequestId(session.getSourceRequestId()); order.setSourceRequestId(session.getSourceRequestId());

View File

@@ -7,10 +7,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -20,16 +24,21 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Set; import java.util.Set;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service @Service
public class ProfileManager { public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); 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 String profilesRoot;
private final Path resolvedProfilesRoot; private final Path resolvedProfilesRoot;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, String> profileAliases; private final Map<String, String> profileAliases;
private volatile List<ProcessProfileMeta> cachedProcessProfiles;
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) { public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
this.profilesRoot = profilesRoot; this.profilesRoot = profilesRoot;
@@ -68,6 +77,61 @@ public class ProfileManager {
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
public List<BigDecimal> findCompatibleProcessLayers(String machineProfileName) {
if (machineProfileName == null || machineProfileName.isBlank()) {
return List.of();
}
Set<BigDecimal> 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<BigDecimal> sorted = new ArrayList<>(layers);
sorted.sort(Comparator.naturalOrder());
return sorted;
}
public Optional<String> 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<ProcessProfileMeta> 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) { private Path findProfileFile(String name, String type) {
if (!Files.isDirectory(resolvedProfilesRoot)) { if (!Files.isDirectory(resolvedProfilesRoot)) {
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
@@ -215,4 +279,125 @@ public class ProfileManager {
} }
return "any"; return "any";
} }
private List<ProcessProfileMeta> getOrLoadProcessProfiles() {
List<ProcessProfileMeta> cached = cachedProcessProfiles;
if (cached != null) {
return cached;
}
synchronized (this) {
if (cachedProcessProfiles != null) {
return cachedProcessProfiles;
}
List<ProcessProfileMeta> loaded = new ArrayList<>();
if (!Files.isDirectory(resolvedProfilesRoot)) {
cachedProcessProfiles = Collections.emptyList();
return cachedProcessProfiles;
}
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> 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<String> 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<String> compatiblePrinters) {
}
} }

View File

@@ -3,22 +3,29 @@ package com.printcalculator.service;
import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PricingPolicyRepository; import com.printcalculator.repository.PricingPolicyRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.LinkedHashSet;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
@Service @Service
public class QuoteSessionTotalsService { public class QuoteSessionTotalsService {
private final PricingPolicyRepository pricingRepo; private final PricingPolicyRepository pricingRepo;
private final QuoteCalculator quoteCalculator; 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.pricingRepo = pricingRepo;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.nozzleOptionRepo = nozzleOptionRepo;
} }
public QuoteSessionTotals compute(QuoteSession session, List<QuoteLineItem> items) { public QuoteSessionTotals compute(QuoteSession session, List<QuoteLineItem> items) {
@@ -43,7 +50,9 @@ public class QuoteSessionTotalsService {
BigDecimal cadTotal = calculateCadTotal(session); BigDecimal cadTotal = calculateCadTotal(session);
BigDecimal itemsTotal = printItemsTotal.add(cadTotal); 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 shippingCost = calculateShippingCost(items);
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost); BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost);
@@ -52,6 +61,8 @@ public class QuoteSessionTotalsService {
globalMachineCost, globalMachineCost,
cadTotal, cadTotal,
itemsTotal, itemsTotal,
baseSetupFee.setScale(2, RoundingMode.HALF_UP),
nozzleChangeCost,
setupFee, setupFee,
shippingCost, shippingCost,
grandTotal, grandTotal,
@@ -104,6 +115,36 @@ public class QuoteSessionTotalsService {
return BigDecimal.valueOf(2.00); return BigDecimal.valueOf(2.00);
} }
private BigDecimal calculateNozzleChangeCost(List<QuoteLineItem> items) {
if (items == null || items.isEmpty()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
Set<BigDecimal> 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) { private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) { if (quantity == null || quantity < 1) {
return 1; return 1;
@@ -116,6 +157,8 @@ public class QuoteSessionTotalsService {
BigDecimal globalMachineCostChf, BigDecimal globalMachineCostChf,
BigDecimal cadTotalChf, BigDecimal cadTotalChf,
BigDecimal itemsTotalChf, BigDecimal itemsTotalChf,
BigDecimal baseSetupCostChf,
BigDecimal nozzleChangeCostChf,
BigDecimal setupCostChf, BigDecimal setupCostChf,
BigDecimal shippingCostChf, BigDecimal shippingCostChf,
BigDecimal grandTotalChf, BigDecimal grandTotalChf,

View File

@@ -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<String> 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<AdminFilamentMaterialTypeDto> getMaterials() {
return materialRepo.findAll().stream()
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
}
public List<AdminFilamentVariantDto> 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;
}
}

View File

@@ -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<String> 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<AdminFilamentStockDto> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg"));
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> 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<AdminContactRequestDto> 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<AdminContactRequestAttachmentDto> 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<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return toContactRequestDetailDto(saved, attachments);
}
public ResponseEntity<Resource> 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<AdminQuoteSessionDto> getQuoteSessions() {
return quoteSessionRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt"))
.stream()
.map(this::toQuoteSessionDto)
.toList();
}
public List<AdminCadInvoiceDto> 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<AdminContactRequestAttachmentDto> 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<QuoteLineItem> 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<Path> 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");
}
}
}

View File

@@ -263,6 +263,18 @@ public class AdminOrderControllerService {
itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode()); 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.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams()); itemDto.setMaterialGrams(item.getMaterialGrams());

View File

@@ -317,6 +317,12 @@ public class OrderControllerService {
itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode()); 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.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm()); itemDto.setLayerHeightMm(item.getLayerHeightMm());

View File

@@ -11,6 +11,7 @@ import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.storage.ClamAVService;
@@ -41,6 +42,7 @@ public class QuoteSessionItemService {
private final ClamAVService clamAVService; private final ClamAVService clamAVService;
private final QuoteStorageService quoteStorageService; private final QuoteStorageService quoteStorageService;
private final QuoteSessionSettingsService settingsService; private final QuoteSessionSettingsService settingsService;
private final ProfileManager profileManager;
public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo, public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo,
QuoteSessionRepository sessionRepo, QuoteSessionRepository sessionRepo,
@@ -49,7 +51,8 @@ public class QuoteSessionItemService {
OrcaProfileResolver orcaProfileResolver, OrcaProfileResolver orcaProfileResolver,
ClamAVService clamAVService, ClamAVService clamAVService,
QuoteStorageService quoteStorageService, QuoteStorageService quoteStorageService,
QuoteSessionSettingsService settingsService) { QuoteSessionSettingsService settingsService,
ProfileManager profileManager) {
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.slicerService = slicerService; this.slicerService = slicerService;
@@ -58,6 +61,7 @@ public class QuoteSessionItemService {
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.quoteStorageService = quoteStorageService; this.quoteStorageService = quoteStorageService;
this.settingsService = settingsService; this.settingsService = settingsService;
this.profileManager = profileManager;
} }
public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException { 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); OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
String processProfile = resolveProcessProfile(settings); String processProfile = resolveProcessProfile(
settings,
profiles.machineProfileName(),
nozzleDiameter,
layerHeight
);
Map<String, String> processOverrides = new HashMap<>(); Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString()); 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) { if (settings.getLayerHeight() == null) {
return "standard"; return "standard";
} }

View File

@@ -34,6 +34,9 @@ public class QuoteSessionResponseAssembler {
response.put("printItemsTotalChf", totals.printItemsTotalChf()); response.put("printItemsTotalChf", totals.printItemsTotalChf());
response.put("cadTotalChf", totals.cadTotalChf()); response.put("cadTotalChf", totals.cadTotalChf());
response.put("itemsTotalChf", totals.itemsTotalChf()); 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("shippingCostChf", totals.shippingCostChf());
response.put("globalMachineCostChf", totals.globalMachineCostChf()); response.put("globalMachineCostChf", totals.globalMachineCostChf());
response.put("grandTotalChf", totals.grandTotalChf()); response.put("grandTotalChf", totals.grandTotalChf());

View File

@@ -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<String, Object> 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";
};
}
}

View File

@@ -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<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> 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<MultipartFile> 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");
}
}
}

View File

@@ -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<MultipartFile> 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<CustomQuoteRequest> 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.");
}
}
}

View File

@@ -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<String, Object> 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<String, Object> 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;
}
}

View File

@@ -3,6 +3,8 @@ package com.printcalculator.service;
import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.entity.NozzleOption;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PricingPolicyRepository; import com.printcalculator.repository.PricingPolicyRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -20,13 +22,15 @@ import static org.mockito.Mockito.when;
class QuoteSessionTotalsServiceTest { class QuoteSessionTotalsServiceTest {
private PricingPolicyRepository pricingRepo; private PricingPolicyRepository pricingRepo;
private QuoteCalculator quoteCalculator; private QuoteCalculator quoteCalculator;
private NozzleOptionRepository nozzleOptionRepo;
private QuoteSessionTotalsService service; private QuoteSessionTotalsService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
pricingRepo = mock(PricingPolicyRepository.class); pricingRepo = mock(PricingPolicyRepository.class);
quoteCalculator = mock(QuoteCalculator.class); quoteCalculator = mock(QuoteCalculator.class);
service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator); nozzleOptionRepo = mock(NozzleOptionRepository.class);
service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator, nozzleOptionRepo);
} }
@Test @Test
@@ -77,6 +81,51 @@ class QuoteSessionTotalsServiceTest {
assertAmountEquals("120.00", totals.grandTotalChf()); 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) { private void assertAmountEquals(String expected, BigDecimal actual) {
assertTrue(new BigDecimal(expected).compareTo(actual) == 0, assertTrue(new BigDecimal(expected).compareTo(actual) == 0,
"Expected " + expected + " but got " + actual); "Expected " + expected + " but got " + actual);

View File

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

View File

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

View File

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

View File

@@ -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<Path> 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<OrderDto> 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<byte[]> 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;
}
}

View File

@@ -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<String, Object> 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"));
}
}

View File

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

View File

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

View File

@@ -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<Map<String, Object>> dataCaptor = (ArgumentCaptor<Map<String, Object>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Map.class);
ArgumentCaptor<String> toCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> subjectCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> templateCaptor = ArgumentCaptor.forClass(String.class);
verify(emailNotificationService, times(2)).sendEmail(
toCaptor.capture(),
subjectCaptor.capture(),
templateCaptor.capture(),
dataCaptor.capture()
);
List<String> 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;
}
}

View File

@@ -193,17 +193,22 @@
</p> </p>
<p class="item-meta"> <p class="item-meta">
Qta: {{ item.quantity }} | Materiale: Qta: {{ item.quantity }} | Materiale:
{{ item.materialCode || "-" }} | Colore: {{ getItemMaterialLabel(item) }} | Colore:
<span <span
class="color-swatch" class="color-swatch"
*ngIf="isHexColor(item.colorCode)" *ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="item.colorCode" [style.background-color]="colorHex"
></span> ></span>
<span>{{ item.colorCode || "-" }}</span> <span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti: {{ item.infillPercent ?? "-" }}% | Supporti:
{{ item.supportsEnabled ? "Sì" : "No" }} {{ formatSupports(item.supportsEnabled) }}
| Riga: | Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }} {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p> </p>
@@ -283,10 +288,11 @@
<div class="file-color-row" *ngFor="let item of selectedOrder.items"> <div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span> <span class="filename">{{ item.originalFilename }}</span>
<span class="file-color"> <span class="file-color">
{{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm {{ getItemMaterialLabel(item) }} | Colore:
{{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm
| {{ item.layerHeightMm ?? "-" }} mm | | {{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} | {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} {{ formatSupportsState(item.supportsEnabled) }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
AdminOrder, AdminOrder,
AdminOrderItem,
AdminOrdersService, AdminOrdersService,
} from '../services/admin-orders.service'; } from '../services/admin-orders.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; 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 { isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId; return this.selectedOrder?.id === orderId;
} }

View File

@@ -8,6 +8,10 @@ export interface AdminOrderItem {
originalFilename: string; originalFilename: string;
materialCode: string; materialCode: string;
colorCode: string; colorCode: string;
filamentVariantId?: number;
filamentVariantDisplayName?: string;
filamentColorName?: string;
filamentColorHex?: string;
quality?: string; quality?: string;
nozzleDiameterMm?: number; nozzleDiameterMm?: number;
layerHeightMm?: number; layerHeightMm?: number;

View File

@@ -5,11 +5,11 @@
<div class="result-grid"> <div class="result-grid">
<app-summary-card <app-summary-card
class="item full-width" class="item full-width"
[label]="'CALC.COST' | translate" [label]="'CHECKOUT.SUBTOTAL' | translate"
[large]="true" [large]="true"
[highlight]="true" [highlight]="true"
> >
{{ totals().price | currency: result().currency }} {{ costBreakdown().subtotal | currency: result().currency }}
</app-summary-card> </app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate"> <app-summary-card [label]="'CALC.TIME' | translate">
@@ -22,18 +22,6 @@
</div> </div>
<div class="setup-note"> <div class="setup-note">
<small>{{
"CALC.SETUP_NOTE"
| translate
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
@if ((result().cadTotal || 0) > 0) {
<small class="shipping-note" style="color: #666">
Servizio CAD: {{ result().cadTotal | currency: result().currency }}
</small>
<br />
}
<small class="shipping-note" style="color: #666">{{ <small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate "CALC.SHIPPING_NOTE" | translate
}}</small> }}</small>
@@ -63,7 +51,7 @@
<span class="file-details"> <span class="file-details">
{{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g | {{ item.unitWeight | number: "1.0-0" }}g |
<span class="material-chip">{{ item.material || "N/D" }}</span> {{ item.material || "N/D" }}
@if (getItemDifferenceLabel(item.fileName, item.material)) { @if (getItemDifferenceLabel(item.fileName, item.material)) {
| |
<small class="item-settings-diff"> <small class="item-settings-diff">
@@ -108,6 +96,25 @@
} }
</div> </div>
<div class="cost-breakdown">
<div class="cost-row">
<span>Costo di Avvio</span>
<span>{{ costBreakdown().baseSetup | currency: result().currency }}</span>
</div>
@if (costBreakdown().nozzleChange > 0) {
<div class="cost-row">
<span>Cambio Ugello</span>
<span>{{
costBreakdown().nozzleChange | currency: result().currency
}}</span>
</div>
}
<div class="cost-total">
<span>Totale</span>
<span>{{ costBreakdown().total | currency: result().currency }}</span>
</div>
</div>
<div class="actions"> <div class="actions">
<div class="actions-left"> <div class="actions-left">
<app-button variant="secondary" (click)="consult.emit()"> <app-button variant="secondary" (click)="consult.emit()">

View File

@@ -55,19 +55,6 @@
color: var(--color-text-muted); 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 { .item-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -218,3 +205,35 @@
color: #6f5b1a; color: #6f5b1a;
font-size: 0.9rem; 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);
}

View File

@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { QuoteResultComponent } from './quote-result.component'; import { QuoteResultComponent } from './quote-result.component';
import { QuoteResult } from '../../services/quote-estimator.service'; import { QuoteResult } from '../../services/quote-estimator.service';
@@ -38,7 +39,11 @@ describe('QuoteResultComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [QuoteResultComponent, TranslateModule.forRoot()], imports: [
QuoteResultComponent,
TranslateModule.forRoot(),
HttpClientTestingModule,
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(QuoteResultComponent); fixture = TestBed.createComponent(QuoteResultComponent);

View File

@@ -134,17 +134,37 @@ export class QuoteResultComponent implements OnDestroy {
this.items().some((item) => item.quantity > this.directOrderLimit), this.items().some((item) => item.quantity > this.directOrderLimit),
); );
totals = computed(() => { costBreakdown = computed(() => {
const currentItems = this.items(); const currentItems = this.items();
const setup = this.result().setupCost;
const cad = this.result().cadTotal || 0; 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 time = 0;
let weight = 0; let weight = 0;
currentItems.forEach((i) => { currentItems.forEach((i) => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity; time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity; weight += i.unitWeight * i.quantity;
}); });
@@ -153,7 +173,7 @@ export class QuoteResultComponent implements OnDestroy {
const minutes = Math.ceil((time % 3600) / 60); const minutes = Math.ceil((time % 3600) / 60);
return { return {
price: Math.round(price * 100) / 100, price: this.costBreakdown().total,
hours, hours,
minutes, minutes,
weight: Math.ceil(weight), weight: Math.ceil(weight),

View File

@@ -107,7 +107,27 @@
>. >.
</p> </p>
@if (mode() === "advanced") { @if (mode() === "easy") {
<div class="easy-global-controls">
<label class="easy-global-field">
<span>{{ "CALC.MATERIAL" | translate }}</span>
<select formControlName="material">
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
<label class="easy-global-field">
<span>{{ "CALC.QUALITY" | translate }}</span>
<select formControlName="quality">
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
</div>
} @else {
<div class="sync-settings"> <div class="sync-settings">
<label class="sync-settings-toggle"> <label class="sync-settings-toggle">
<input <input

View File

@@ -194,6 +194,48 @@
} }
} }
.easy-global-controls {
margin-top: var(--space-4);
margin-bottom: var(--space-1);
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.easy-global-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
span {
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--color-text-muted);
}
select {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.55rem 0.75rem;
background: var(--color-bg-card);
font-size: 0.96rem;
font-weight: 600;
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25);
}
}
}
.sync-settings { .sync-settings {
margin-top: var(--space-4); margin-top: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);

View File

@@ -51,6 +51,8 @@ export interface QuoteItem {
export interface QuoteResult { export interface QuoteResult {
sessionId?: string; sessionId?: string;
items: QuoteItem[]; items: QuoteItem[];
baseSetupCost?: number;
nozzleChangeCost?: number;
setupCost: number; setupCost: number;
globalMachineCost: number; globalMachineCost: number;
cadHours?: number; cadHours?: number;
@@ -382,9 +384,11 @@ export class QuoteEstimatorService {
); );
const grandTotal = Number(sessionData?.grandTotalChf); const grandTotal = Number(sessionData?.grandTotalChf);
const effectiveSetupCost =
Number(sessionData?.setupCostChf ?? session?.setupCostChf ?? 0);
const fallbackTotal = const fallbackTotal =
Number(sessionData?.itemsTotalChf || 0) + Number(sessionData?.itemsTotalChf || 0) +
Number(session?.setupCostChf || 0) + effectiveSetupCost +
Number(sessionData?.shippingCostChf || 0); Number(sessionData?.shippingCostChf || 0);
return { return {
@@ -411,7 +415,11 @@ export class QuoteEstimatorService {
? Number(item.nozzleDiameterMm) ? Number(item.nozzleDiameterMm)
: undefined, : undefined,
})), })),
setupCost: Number(session?.setupCostChf || 0), baseSetupCost: Number(
sessionData?.baseSetupCostChf ?? session?.setupCostChf ?? 0,
),
nozzleChangeCost: Number(sessionData?.nozzleChangeCostChf ?? 0),
setupCost: effectiveSetupCost,
globalMachineCost: Number(sessionData?.globalMachineCostChf || 0), globalMachineCost: Number(sessionData?.globalMachineCostChf || 0),
cadHours: Number(session?.cadHours || 0), cadHours: Number(session?.cadHours || 0),
cadTotal: Number(sessionData?.cadTotalChf || 0), cadTotal: Number(sessionData?.cadTotalChf || 0),

View File

@@ -249,11 +249,13 @@
{{ "CHECKOUT.MATERIAL" | translate }}: {{ "CHECKOUT.MATERIAL" | translate }}:
{{ itemMaterial(item) }} {{ itemMaterial(item) }}
</span> </span>
<span <span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
*ngIf="item.colorCode" <span
class="color-dot" class="color-dot"
[style.background-color]="item.colorCode" [style.background-color]="itemColorSwatch(item)"
></span> ></span>
<span class="color-name">{{ itemColorLabel(item) }}</span>
</span>
</div> </div>
<div class="item-specs-sub"> <div class="item-specs-sub">
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | {{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
@@ -328,7 +330,14 @@
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span> <span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span>
<span>{{ session.session.setupCostChf | currency: "CHF" }}</span> <span>{{
(session.baseSetupCostChf ?? session.session.setupCostChf)
| currency: "CHF"
}}</span>
</div>
<div class="total-row" *ngIf="(session.nozzleChangeCostChf || 0) > 0">
<span>Cambio Ugello</span>
<span>{{ session.nozzleChangeCostChf | currency: "CHF" }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ "CHECKOUT.SHIPPING" | translate }}</span> <span>{{ "CHECKOUT.SHIPPING" | translate }}</span>

View File

@@ -230,12 +230,24 @@ app-toggle-selector.user-type-selector-compact {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
.item-color {
display: inline-flex;
align-items: center;
gap: 6px;
}
.color-dot { .color-dot {
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
border: 1px solid var(--color-border); 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);
} }
} }

View File

@@ -18,6 +18,7 @@ import {
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; } from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
import { getColorHex } from '../../core/constants/colors.const';
@Component({ @Component({
selector: 'app-checkout', selector: 'app-checkout',
@@ -55,6 +56,8 @@ export class CheckoutComponent implements OnInit {
selectedPreviewFile = signal<File | null>(null); selectedPreviewFile = signal<File | null>(null);
selectedPreviewName = signal(''); selectedPreviewName = signal('');
selectedPreviewColor = signal('#c9ced6'); selectedPreviewColor = signal('#c9ced6');
private variantHexById = new Map<number, string>();
private variantHexByColorName = new Map<string, string>();
userTypeOptions: ToggleOption[] = [ userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
@@ -128,6 +131,8 @@ export class CheckoutComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.loadMaterialColorPalette();
this.route.queryParams.subscribe((params) => { this.route.queryParams.subscribe((params) => {
this.sessionId = params['session']; this.sessionId = params['session'];
if (!this.sessionId) { if (!this.sessionId) {
@@ -212,8 +217,40 @@ export class CheckoutComponent implements OnInit {
} }
previewColor(item: any): string { previewColor(item: any): string {
return this.itemColorSwatch(item);
}
itemColorLabel(item: any): string {
const raw = String(item?.colorCode ?? '').trim(); 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 { isPreviewLoading(item: any): boolean {
@@ -250,6 +287,41 @@ export class CheckoutComponent implements OnInit {
this.selectedPreviewColor.set('#c9ced6'); 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 { private loadStlPreviews(session: any): void {
if ( if (
!this.sessionId || !this.sessionId ||