14 Commits

Author SHA1 Message Date
72c0c2c098 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-06 15:14:04 +01:00
b517373538 feat(back-end and front-end): calculator improvements 2026-03-06 15:13:34 +01:00
printcalc-ci
575a540a70 style: apply prettier formatting 2026-03-05 20:56:22 +00:00
042c254691 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 14s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m7s
# Conflicts:
#	frontend/src/app/features/checkout/checkout.component.html
2026-03-05 21:46:23 +01:00
7a699d2adf feat(back-end and front-end): calculator improvements 2026-03-05 21:46:11 +01:00
printcalc-ci
811e0f441b style: apply prettier formatting 2026-03-05 17:31:15 +00:00
235fe7780d feat(back-end and front-end): calculator improvements
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 18:30:37 +01:00
93b0b55f43 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 32s
2026-03-05 18:30:11 +01:00
cdd0d22d9a Merge branch 'dev' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 17:43:57 +01:00
0a3510e996 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options 2026-03-05 17:29:11 +01:00
819ac01d44 Merge branch 'dev' into feat/calculator-options 2026-03-05 17:28:57 +01:00
printcalc-ci
aa6322e928 style: apply prettier formatting 2026-03-05 16:28:39 +00:00
a7491130fb chore(back-end and front-end): refractor and improvements calculator
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 17:28:07 +01:00
1effd4926f Merge pull request 'feat/calculator-options' (#23) from feat/calculator-options into dev
All checks were successful
Build and Deploy / build-and-push (push) Successful in 45s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #23
2026-03-05 15:14:08 +01:00
59 changed files with 5777 additions and 3112 deletions

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

@@ -1,146 +1,62 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.*; import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*; import com.printcalculator.dto.OrderDto;
import com.printcalculator.repository.*; import com.printcalculator.service.order.OrderControllerService;
import com.printcalculator.service.payment.InvoicePdfRenderingService; import jakarta.validation.Valid;
import com.printcalculator.service.OrderService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.payment.TwintPaymentService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException; import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.UUID;
import java.util.Base64;
import java.util.Set;
import java.util.stream.Collectors;
import java.net.URI;
import java.util.Locale;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> PERSONAL_DATA_REDACTED_STATUSES = Set.of(
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED"
);
private final OrderService orderService; private final OrderControllerService orderControllerService;
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final CustomerRepository customerRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderController(OrderControllerService orderControllerService) {
public OrderController(OrderService orderService, this.orderControllerService = orderControllerService;
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.customerRepo = customerRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
} }
// 1. Create Order from Quote
@PostMapping("/from-quote/{quoteSessionId}") @PostMapping("/from-quote/{quoteSessionId}")
@Transactional @Transactional
public ResponseEntity<OrderDto> createOrderFromQuote( public ResponseEntity<OrderDto> createOrderFromQuote(
@PathVariable UUID quoteSessionId, @PathVariable UUID quoteSessionId,
@Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request @Valid @RequestBody CreateOrderRequest request
) { ) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request); return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request));
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return ResponseEntity.ok(convertToDto(order, items));
} }
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<Void> uploadOrderItemFile( public ResponseEntity<Void> uploadOrderItemFile(
@PathVariable UUID orderId, @PathVariable UUID orderId,
@PathVariable UUID orderItemId, @PathVariable UUID orderItemId,
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
boolean uploaded = orderControllerService.uploadOrderItemFile(orderId, orderItemId, file);
OrderItem item = orderItemRepo.findById(orderItemId) if (!uploaded) {
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
if (!item.getOrder().getId().equals(orderId)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String relativePath = item.getStoredRelativePath();
Path destinationRelativePath;
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID() + "." + ext;
destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename);
item.setStoredRelativePath(destinationRelativePath.toString());
item.setStoredFilename(storedFilename);
} else {
destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (destinationRelativePath == null) {
return ResponseEntity.badRequest().build();
}
}
storageService.store(file, destinationRelativePath);
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/{orderId}") @GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) { public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return orderRepo.findById(orderId) return orderControllerService.getOrder(orderId)
.map(o -> { .map(ResponseEntity::ok)
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
return ResponseEntity.ok(convertToDto(o, items));
})
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@@ -150,89 +66,29 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody Map<String, String> payload @RequestBody Map<String, String> payload
) { ) {
String method = payload.get("method"); return orderControllerService.reportPayment(orderId, payload.get("method"))
paymentService.reportPayment(orderId, method); .map(ResponseEntity::ok)
return getOrder(orderId); .orElse(ResponseEntity.notFound().build());
} }
@GetMapping("/{orderId}/confirmation") @GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return generateDocument(orderId, true); return orderControllerService.getConfirmation(orderId);
} }
@GetMapping("/{orderId}/invoice") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
// Paid invoices are sent by email after back-office payment confirmation.
// The public endpoint must not expose a "paid" invoice download.
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// Fallback to on-the-fly generation if the stored file is missing or unreadable.
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String typePrefix = isConfirmation ? "confirmation-" : "invoice-";
String truncatedUuid = order.getId().toString().substring(0, 8);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path buildConfirmationPdfRelativePath(Order order) {
return Path.of(
"orders",
order.getId().toString(),
"documents",
"confirmation-" + getDisplayOrderNumber(order) + ".pdf"
);
}
@GetMapping("/{orderId}/twint") @GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.getTwintPayment(orderId);
if (order == null) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(order, 360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order));
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
return ResponseEntity.ok(data);
} }
@GetMapping("/{orderId}/twint/open") @GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.openTwintPayment(orderId);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
} }
@GetMapping("/{orderId}/twint/qr") @GetMapping("/{orderId}/twint/qr")
@@ -240,150 +96,6 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size @RequestParam(defaultValue = "320") int size
) { ) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.getTwintQr(orderId, size);
if (order == null) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(order, normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
} }
private String getExtension(String filename) {
if (filename == null) return "stl";
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "stl";
}
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 "stl";
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
if (!redactPersonalData) {
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
}
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
if (!redactPersonalData) {
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!order.getShippingSameAsBilling()) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuality(i.getQuality());
idto.setNozzleDiameterMm(i.getNozzleDiameterMm());
idto.setLayerHeightMm(i.getLayerHeightMm());
idto.setInfillPercent(i.getInfillPercent());
idto.setInfillPattern(i.getInfillPattern());
idto.setSupportsEnabled(i.getSupportsEnabled());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).collect(Collectors.toList());
dto.setItems(itemDtos);
return dto;
}
private boolean shouldRedactPersonalData(String status) {
if (status == null || status.isBlank()) {
return false;
}
return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
} }

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

@@ -1,25 +1,9 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto; import com.printcalculator.service.order.AdminOrderControllerService;
import com.printcalculator.entity.*;
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.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -28,80 +12,30 @@ 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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController @RestController
@RequestMapping("/api/admin/orders") @RequestMapping("/api/admin/orders")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOrderController { public class AdminOrderController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo; private final AdminOrderControllerService adminOrderControllerService;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public AdminOrderController( public AdminOrderController(AdminOrderControllerService adminOrderControllerService) {
OrderRepository orderRepo, this.adminOrderControllerService = adminOrderControllerService;
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
QuoteLineItemRepository quoteLineItemRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
} }
@GetMapping @GetMapping
public ResponseEntity<List<OrderDto>> listOrders() { public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc() return ResponseEntity.ok(adminOrderControllerService.listOrders());
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/{orderId}") @GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) { public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId));
} }
@PostMapping("/{orderId}/payments/confirm") @PostMapping("/{orderId}/payments/confirm")
@@ -110,13 +44,7 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload @RequestBody(required = false) Map<String, String> payload
) { ) {
getOrderOrThrow(orderId); return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, payload));
String method = payload != null ? payload.get("method") : null;
if (method == null || method.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Payment method is required");
}
paymentService.updatePaymentMethod(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
} }
@PostMapping("/{orderId}/status") @PostMapping("/{orderId}/status")
@@ -125,28 +53,7 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload @RequestBody AdminOrderStatusUpdateRequest payload
) { ) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload));
throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
String previousStatus = order.getStatus();
order.setStatus(normalizedStatus);
Order savedOrder = orderRepo.save(order);
// Notify customer only on transition to SHIPPED.
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
}
return ResponseEntity.ok(toOrderDto(savedOrder));
} }
@GetMapping("/{orderId}/items/{orderItemId}/file") @GetMapping("/{orderId}/items/{orderItemId}/file")
@@ -154,290 +61,16 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@PathVariable UUID orderItemId @PathVariable UUID orderItemId
) { ) {
OrderItem item = orderItemRepo.findById(orderItemId) return adminOrderControllerService.downloadOrderItemFile(orderId, orderItemId);
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = storageService.loadAsResource(safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
} }
@GetMapping("/{orderId}/documents/confirmation") @GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) { public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true); return adminOrderControllerService.downloadOrderConfirmation(orderId);
} }
@GetMapping("/{orderId}/documents/invoice") @GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false); return adminOrderControllerService.downloadOrderInvoice(orderId);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) {
try {
return storageService.loadAsResource(safeRelativePath);
} catch (Exception primaryFailure) {
Path sourceQuotePath = resolveFallbackQuoteItemPath(item);
if (sourceQuotePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
storageService.store(sourceQuotePath, safeRelativePath);
return storageService.loadAsResource(safeRelativePath);
} catch (Exception copyFailure) {
try {
Resource quoteResource = new UrlResource(sourceQuotePath.toUri());
if (quoteResource.exists() || quoteResource.isReadable()) {
return quoteResource;
}
} catch (Exception ignored) {
// fall through to 404
}
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
}
private Path resolveFallbackQuoteItemPath(OrderItem orderItem) {
Order order = orderItem.getOrder();
QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null;
UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null;
if (sourceSessionId == null) {
return null;
}
String targetFilename = normalizeFilename(orderItem.getOriginalFilename());
if (targetFilename == null) {
return null;
}
return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream()
.filter(q -> targetFilename.equals(normalizeFilename(q.getOriginalFilename())))
.sorted(Comparator.comparingInt((QuoteLineItem q) -> scoreQuoteMatch(orderItem, q)).reversed())
.map(q -> resolveStoredQuotePath(q.getStoredPath(), sourceSessionId))
.filter(path -> path != null && Files.exists(path))
.findFirst()
.orElse(null);
}
private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) {
int score = 0;
if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) {
score += 4;
}
if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) {
score += 3;
}
if (orderItem.getMaterialCode() != null
&& quoteItem.getMaterialCode() != null
&& orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) {
score += 3;
}
if (orderItem.getMaterialGrams() != null
&& quoteItem.getMaterialGrams() != null
&& orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) {
score += 2;
}
return score;
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return null;
}
return filename.trim();
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
} }
} }

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

@@ -7,6 +7,7 @@ public class PrintSettingsDto {
// Common // Common
private String material; // e.g. "PLA", "PLA TOUGH", "PETG" private String material; // e.g. "PLA", "PLA TOUGH", "PETG"
private String color; // e.g. "White", "#FFFFFF" private String color; // e.g. "White", "#FFFFFF"
private Integer quantity;
private Long filamentVariantId; private Long filamentVariantId;
private Long printerMachineId; private Long printerMachineId;
@@ -58,6 +59,14 @@ public class PrintSettingsDto {
this.filamentVariantId = filamentVariantId; this.filamentVariantId = filamentVariantId;
} }
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public Long getPrinterMachineId() { public Long getPrinterMachineId() {
return printerMachineId; return printerMachineId;
} }

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

@@ -0,0 +1,435 @@
package com.printcalculator.service.order;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
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.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminOrderControllerService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public AdminOrderControllerService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
QuoteLineItemRepository quoteLineItemRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
}
public List<OrderDto> listOrders() {
return orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
}
public OrderDto getOrder(UUID orderId) {
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updatePaymentMethod(UUID orderId, Map<String, String> payload) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
if (method == null || method.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Payment method is required");
}
paymentService.updatePaymentMethod(orderId, method);
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updateOrderStatus(UUID orderId, AdminOrderStatusUpdateRequest payload) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
String previousStatus = order.getStatus();
order.setStatus(normalizedStatus);
Order savedOrder = orderRepo.save(order);
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
}
return toOrderDto(savedOrder);
}
public ResponseEntity<Resource> downloadOrderItemFile(UUID orderId, UUID orderItemId) {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
public ResponseEntity<byte[]> downloadOrderConfirmation(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
public ResponseEntity<byte[]> downloadOrderInvoice(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
dto.setPaymentStatus(payment.getStatus());
dto.setPaymentMethod(payment.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId());
itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode());
if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
}
itemDto.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm());
itemDto.setInfillPercent(item.getInfillPercent());
itemDto.setInfillPattern(item.getInfillPattern());
itemDto.setSupportsEnabled(item.getSupportsEnabled());
itemDto.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams());
itemDto.setUnitPriceChf(item.getUnitPriceChf());
itemDto.setLineTotalChf(item.getLineTotalChf());
return itemDto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) {
try {
return storageService.loadAsResource(safeRelativePath);
} catch (Exception primaryFailure) {
Path sourceQuotePath = resolveFallbackQuoteItemPath(item);
if (sourceQuotePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
storageService.store(sourceQuotePath, safeRelativePath);
return storageService.loadAsResource(safeRelativePath);
} catch (Exception copyFailure) {
try {
Resource quoteResource = new UrlResource(sourceQuotePath.toUri());
if (quoteResource.exists() || quoteResource.isReadable()) {
return quoteResource;
}
} catch (Exception ignored) {
// fall through to 404
}
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
}
private Path resolveFallbackQuoteItemPath(OrderItem orderItem) {
Order order = orderItem.getOrder();
QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null;
UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null;
if (sourceSessionId == null) {
return null;
}
String targetFilename = normalizeFilename(orderItem.getOriginalFilename());
if (targetFilename == null) {
return null;
}
return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream()
.filter(quoteItem -> targetFilename.equals(normalizeFilename(quoteItem.getOriginalFilename())))
.sorted(Comparator.comparingInt((QuoteLineItem quoteItem) -> scoreQuoteMatch(orderItem, quoteItem)).reversed())
.map(quoteItem -> resolveStoredQuotePath(quoteItem.getStoredPath(), sourceSessionId))
.filter(path -> path != null && Files.exists(path))
.findFirst()
.orElse(null);
}
private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) {
int score = 0;
if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) {
score += 4;
}
if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) {
score += 3;
}
if (orderItem.getMaterialCode() != null
&& quoteItem.getMaterialCode() != null
&& orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) {
score += 3;
}
if (orderItem.getMaterialGrams() != null
&& quoteItem.getMaterialGrams() != null
&& orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) {
score += 2;
}
return score;
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return null;
}
return filename.trim();
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
}

View File

@@ -0,0 +1,358 @@
package com.printcalculator.service.order;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
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.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URI;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class OrderControllerService {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> PERSONAL_DATA_REDACTED_STATUSES = Set.of(
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED"
);
private final OrderService orderService;
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderControllerService(OrderService orderService,
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
}
@Transactional
public OrderDto createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request);
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return convertToDto(order, items);
}
@Transactional
public boolean uploadOrderItemFile(UUID orderId, UUID orderItemId, MultipartFile file) throws IOException {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
if (!item.getOrder().getId().equals(orderId)) {
return false;
}
String relativePath = item.getStoredRelativePath();
Path destinationRelativePath;
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID() + "." + ext;
destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename);
item.setStoredRelativePath(destinationRelativePath.toString());
item.setStoredFilename(storedFilename);
} else {
destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (destinationRelativePath == null) {
return false;
}
}
storageService.store(file, destinationRelativePath);
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
return true;
}
public Optional<OrderDto> getOrder(UUID orderId) {
return orderRepo.findById(orderId)
.map(order -> {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return convertToDto(order, items);
});
}
@Transactional
public Optional<OrderDto> reportPayment(UUID orderId, String method) {
paymentService.reportPayment(orderId, method);
return getOrder(orderId);
}
public ResponseEntity<byte[]> getConfirmation(UUID orderId) {
return generateDocument(orderId, true);
}
public ResponseEntity<Map<String, String>> getTwintPayment(UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(order, 360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order));
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
return ResponseEntity.ok(data);
}
public ResponseEntity<Void> openTwintPayment(UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
}
public ResponseEntity<byte[]> getTwintQr(UUID orderId, int size) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(order, normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
}
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// Fallback to on-the-fly generation if the stored file is missing or unreadable.
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String typePrefix = isConfirmation ? "confirmation-" : "invoice-";
String truncatedUuid = order.getId().toString().substring(0, 8);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path buildConfirmationPdfRelativePath(Order order) {
return Path.of(
"orders",
order.getId().toString(),
"documents",
"confirmation-" + getDisplayOrderNumber(order) + ".pdf"
);
}
private String getExtension(String filename) {
if (filename == null) {
return "stl";
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "stl";
}
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 "stl";
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
dto.setPaymentStatus(payment.getStatus());
dto.setPaymentMethod(payment.getMethod());
});
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
if (!redactPersonalData) {
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
}
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
if (!redactPersonalData) {
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
}
List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId());
itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode());
if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
}
itemDto.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm());
itemDto.setInfillPercent(item.getInfillPercent());
itemDto.setInfillPattern(item.getInfillPattern());
itemDto.setSupportsEnabled(item.getSupportsEnabled());
itemDto.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams());
itemDto.setUnitPriceChf(item.getUnitPriceChf());
itemDto.setLineTotalChf(item.getLineTotalChf());
return itemDto;
}).collect(Collectors.toList());
dto.setItems(itemDtos);
return dto;
}
private boolean shouldRedactPersonalData(String status) {
if (status == null || status.isBlank()) {
return false;
}
return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

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";
} }
@@ -208,7 +239,7 @@ public class QuoteSessionItemService {
item.setQuoteSession(session); item.setQuoteSession(session);
item.setOriginalFilename(originalFilename); item.setOriginalFilename(originalFilename);
item.setStoredPath(quoteStorageService.toStoredPath(persistentPath)); item.setStoredPath(quoteStorageService.toStoredPath(persistentPath));
item.setQuantity(1); item.setQuantity(normalizeQuantity(settings.getQuantity()));
item.setColorCode(selectedVariant.getColorName()); item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant); item.setFilamentVariant(selectedVariant);
item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null
@@ -248,4 +279,11 @@ public class QuoteSessionItemService {
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
return item; return item;
} }
private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) {
return 1;
}
return quantity;
}
} }

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

@@ -2,14 +2,12 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderDto;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.repository.CustomerRepository;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.order.OrderControllerService;
import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService; import com.printcalculator.service.storage.StorageService;
@@ -41,12 +39,6 @@ class OrderControllerPrivacyTest {
@Mock @Mock
private OrderItemRepository orderItemRepo; private OrderItemRepository orderItemRepo;
@Mock @Mock
private QuoteSessionRepository quoteSessionRepo;
@Mock
private QuoteLineItemRepository quoteLineItemRepo;
@Mock
private CustomerRepository customerRepo;
@Mock
private StorageService storageService; private StorageService storageService;
@Mock @Mock
private InvoicePdfRenderingService invoiceService; private InvoicePdfRenderingService invoiceService;
@@ -63,13 +55,10 @@ class OrderControllerPrivacyTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
controller = new OrderController( OrderControllerService orderControllerService = new OrderControllerService(
orderService, orderService,
orderRepo, orderRepo,
orderItemRepo, orderItemRepo,
quoteSessionRepo,
quoteLineItemRepo,
customerRepo,
storageService, storageService,
invoiceService, invoiceService,
qrBillService, qrBillService,
@@ -77,6 +66,7 @@ class OrderControllerPrivacyTest {
paymentService, paymentService,
paymentRepo paymentRepo
); );
controller = new OrderController(orderControllerService);
} }
@Test @Test

View File

@@ -6,6 +6,8 @@ import com.printcalculator.entity.Order;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.service.order.AdminOrderControllerService;
import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService; import com.printcalculator.service.payment.QrBillService;
@@ -41,6 +43,8 @@ class AdminOrderControllerStatusValidationTest {
@Mock @Mock
private PaymentRepository paymentRepository; private PaymentRepository paymentRepository;
@Mock @Mock
private QuoteLineItemRepository quoteLineItemRepository;
@Mock
private PaymentService paymentService; private PaymentService paymentService;
@Mock @Mock
private StorageService storageService; private StorageService storageService;
@@ -55,16 +59,18 @@ class AdminOrderControllerStatusValidationTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
controller = new AdminOrderController( AdminOrderControllerService adminOrderControllerService = new AdminOrderControllerService(
orderRepository, orderRepository,
orderItemRepository, orderItemRepository,
paymentRepository, paymentRepository,
quoteLineItemRepository,
paymentService, paymentService,
storageService, storageService,
invoicePdfRenderingService, invoicePdfRenderingService,
qrBillService, qrBillService,
eventPublisher eventPublisher
); );
controller = new AdminOrderController(adminOrderControllerService);
} }
@Test @Test

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:
| {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% {{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm
| {{ item.infillPattern || "-" }} | | {{ item.layerHeightMm ?? "-" }} mm |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} |
{{ 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

@@ -149,7 +149,9 @@
{{ item.layerHeightMm ?? "-" }} mm | {{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% | {{ item.infillPercent ?? "-" }}% |
{{ item.infillPattern || "-" }} | {{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} {{
item.supportsEnabled ? "Supporti ON" : "Supporti OFF"
}}
</td> </td>
<td>{{ item.status }}</td> <td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency: "CHF" }}</td> <td>{{ item.unitPriceChf | currency: "CHF" }}</td>

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

@@ -72,7 +72,10 @@ export class CalculatorPageComponent implements OnInit {
Record<string, { differences: string[] }> Record<string, { differences: string[] }>
>({}); >({});
private baselinePrintSettings: TrackedPrintSettings | null = null; private baselinePrintSettings: TrackedPrintSettings | null = null;
private baselineItemSettingsByFileName = new Map<string, TrackedPrintSettings>(); private baselineItemSettingsByFileName = new Map<
string,
TrackedPrintSettings
>();
@ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef; @ViewChild('resultCol') resultCol!: ElementRef;
@@ -200,6 +203,13 @@ export class CalculatorPageComponent implements OnInit {
this.uploadForm.patchSettings(session); this.uploadForm.patchSettings(session);
items.forEach((item, index) => { items.forEach((item, index) => {
// Preserve persisted quantities when restoring from session.
// Without this, setFiles() defaults every item back to 1.
this.uploadForm.updateItemQuantityByIndex(
index,
Number(item.quantity || 1),
);
const tracked = this.toTrackedSettingsFromSessionItem( const tracked = this.toTrackedSettingsFromSessionItem(
item, item,
this.toTrackedSettingsFromSession(session), this.toTrackedSettingsFromSession(session),
@@ -528,7 +538,9 @@ export class CalculatorPageComponent implements OnInit {
}); });
} }
private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings { private toTrackedSettingsFromRequest(
req: QuoteRequest,
): TrackedPrintSettings {
return { return {
mode: req.mode, mode: req.mode,
material: this.normalizeString(req.material || 'PLA'), material: this.normalizeString(req.material || 'PLA'),
@@ -590,17 +602,17 @@ export class CalculatorPageComponent implements OnInit {
item: any, item: any,
fallback: TrackedPrintSettings, fallback: TrackedPrintSettings,
): TrackedPrintSettings { ): TrackedPrintSettings {
const layer = this.normalizeNumber(item?.layerHeightMm, fallback.layerHeight, 3); const layer = this.normalizeNumber(
item?.layerHeightMm,
fallback.layerHeight,
3,
);
return { return {
mode: this.mode(), mode: this.mode(),
material: this.normalizeString(item?.materialCode || fallback.material), material: this.normalizeString(item?.materialCode || fallback.material),
quality: this.normalizeString( quality: this.normalizeString(
item?.quality || item?.quality ||
(layer >= 0.24 (layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'),
? 'draft'
: layer <= 0.12
? 'extra_fine'
: 'standard'),
), ),
nozzleDiameter: this.normalizeNumber( nozzleDiameter: this.normalizeNumber(
item?.nozzleDiameterMm, item?.nozzleDiameterMm,
@@ -616,9 +628,7 @@ export class CalculatorPageComponent implements OnInit {
infillPattern: this.normalizeString( infillPattern: this.normalizeString(
item?.infillPattern || fallback.infillPattern, item?.infillPattern || fallback.infillPattern,
), ),
supportEnabled: Boolean( supportEnabled: Boolean(item?.supportsEnabled ?? fallback.supportEnabled),
item?.supportsEnabled ?? fallback.supportEnabled,
),
}; };
} }

View File

@@ -1,17 +1,15 @@
<app-card> <app-card>
<h3 class="title">{{ "CALC.RESULT" | translate }}</h3> <h3 class="title">{{ "CALC.RESULT" | translate }}</h3>
<!-- Summary Grid (NOW ON TOP) --> <app-price-breakdown
<div class="result-grid"> [rows]="priceBreakdownRows()"
<app-summary-card [total]="costBreakdown().total"
class="item full-width" [currency]="result().currency"
[label]="'CALC.COST' | translate" [totalSuffix]="'*'"
[large]="true" [totalLabelKey]="'CHECKOUT.TOTAL'"
[highlight]="true" ></app-price-breakdown>
>
{{ totals().price | currency: result().currency }}
</app-summary-card>
<div class="result-grid">
<app-summary-card [label]="'CALC.TIME' | translate"> <app-summary-card [label]="'CALC.TIME' | translate">
{{ totals().hours }}h {{ totals().minutes }}m {{ totals().hours }}h {{ totals().minutes }}m
</app-summary-card> </app-summary-card>
@@ -22,18 +20,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,11 +49,11 @@
<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 |
materiale: {{ item.material || "N/D" }} {{ item.material || "N/D" }}
@if (getItemDifferenceLabel(item.fileName)) { @if (getItemDifferenceLabel(item.fileName, item.material)) {
| |
<small class="item-settings-diff"> <small class="item-settings-diff">
{{ getItemDifferenceLabel(item.fileName) }} {{ getItemDifferenceLabel(item.fileName, item.material) }}
</small> </small>
} }
</span> </span>
@@ -83,6 +69,7 @@
[ngModel]="item.quantity" [ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)" (ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)" (blur)="flushQuantityUpdate(i)"
(keydown.enter)="flushQuantityUpdate(i)"
class="qty-input" class="qty-input"
/> />
</div> </div>
@@ -110,7 +97,7 @@
<div class="actions"> <div class="actions">
<div class="actions-left"> <div class="actions-left">
<app-button variant="outline" (click)="consult.emit()"> <app-button variant="secondary" (click)="consult.emit()">
{{ "QUOTE.CONSULT" | translate }} {{ "QUOTE.CONSULT" | translate }}
</app-button> </app-button>
</div> </div>

View File

@@ -20,10 +20,11 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-3); padding: var(--space-3) var(--space-4);
background: var(--color-neutral-50); background: var(--color-bg-card);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
box-shadow: 0 2px 6px rgba(10, 20, 30, 0.04);
} }
.item-info { .item-info {
@@ -121,9 +122,6 @@
gap: var(--space-4); gap: var(--space-4);
} }
} }
.full-width {
grid-column: span 2;
}
.setup-note { .setup-note {
text-align: center; text-align: center;
@@ -149,6 +147,7 @@
.actions-right { .actions-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2);
} }
.actions-right { .actions-right {

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

@@ -1,6 +1,5 @@
import { import {
Component, Component,
OnDestroy,
input, input,
output, output,
signal, signal,
@@ -13,6 +12,10 @@ import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component'; import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import {
PriceBreakdownComponent,
PriceBreakdownRow,
} from '../../../../shared/components/price-breakdown/price-breakdown.component';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service'; import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({ @Component({
@@ -25,14 +28,14 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
AppCardComponent, AppCardComponent,
AppButtonComponent, AppButtonComponent,
SummaryCardComponent, SummaryCardComponent,
PriceBreakdownComponent,
], ],
templateUrl: './quote-result.component.html', templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss', styleUrl: './quote-result.component.scss',
}) })
export class QuoteResultComponent implements OnDestroy { export class QuoteResultComponent {
readonly maxInputQuantity = 500; readonly maxInputQuantity = 500;
readonly directOrderLimit = 100; readonly directOrderLimit = 100;
readonly quantityAutoRefreshMs = 2000;
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
recalculationRequired = input<boolean>(false); recalculationRequired = input<boolean>(false);
@@ -57,13 +60,10 @@ export class QuoteResultComponent implements OnDestroy {
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
private lastSentQuantities = new Map<string, number>(); private lastSentQuantities = new Map<string, number>();
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor() { constructor() {
effect( effect(
() => { () => {
this.clearAllQuantityTimers();
// Initialize local items when result inputs change // Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference // We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map((i) => ({ ...i })); const nextItems = this.result().items.map((i) => ({ ...i }));
@@ -79,17 +79,12 @@ export class QuoteResultComponent implements OnDestroy {
); );
} }
ngOnDestroy(): void {
this.clearAllQuantityTimers();
}
updateQuantity(index: number, newQty: number | string) { updateQuantity(index: number, newQty: number | string) {
const normalizedQty = this.normalizeQuantity(newQty); const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return; if (normalizedQty === null) return;
const item = this.items()[index]; const item = this.items()[index];
if (!item) return; if (!item) return;
const key = item.id ?? item.fileName;
this.items.update((current) => { this.items.update((current) => {
const updated = [...current]; const updated = [...current];
@@ -103,8 +98,6 @@ export class QuoteResultComponent implements OnDestroy {
fileName: item.fileName, fileName: item.fileName,
quantity: normalizedQty, quantity: normalizedQty,
}); });
this.scheduleQuantityRefresh(index, key);
} }
flushQuantityUpdate(index: number): void { flushQuantityUpdate(index: number): void {
@@ -112,7 +105,6 @@ export class QuoteResultComponent implements OnDestroy {
if (!item) return; if (!item) return;
const key = item.id ?? item.fileName; const key = item.id ?? item.fileName;
this.clearQuantityRefreshTimer(key);
const normalizedQty = this.normalizeQuantity(item.quantity); const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return; if (normalizedQty === null) return;
@@ -134,17 +126,57 @@ 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,
};
});
priceBreakdownRows = computed<PriceBreakdownRow[]>(() => {
const breakdown = this.costBreakdown();
return [
{
labelKey: 'CHECKOUT.SUBTOTAL',
amount: breakdown.subtotal,
},
{
labelKey: 'CHECKOUT.SETUP_FEE',
amount: breakdown.baseSetup,
},
{
label: 'Cambio Ugello',
amount: breakdown.nozzleChange,
visible: breakdown.nozzleChange > 0,
},
];
});
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 +185,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),
@@ -168,35 +200,30 @@ export class QuoteResultComponent implements OnDestroy {
return Math.min(qty, this.maxInputQuantity); return Math.min(qty, this.maxInputQuantity);
} }
private scheduleQuantityRefresh(index: number, key: string): void { getItemDifferenceLabel(fileName: string, materialCode?: string): string {
this.clearQuantityRefreshTimer(key);
const timer = setTimeout(() => {
this.quantityTimers.delete(key);
this.flushQuantityUpdate(index);
}, this.quantityAutoRefreshMs);
this.quantityTimers.set(key, timer);
}
private clearQuantityRefreshTimer(key: string): void {
const timer = this.quantityTimers.get(key);
if (!timer) return;
clearTimeout(timer);
this.quantityTimers.delete(key);
}
private clearAllQuantityTimers(): void {
this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear();
}
getItemDifferenceLabel(fileName: string): string {
const differences = const differences =
this.itemSettingsDiffByFileName()[fileName]?.differences || []; this.itemSettingsDiffByFileName()[fileName]?.differences || [];
if (differences.length === 0) return ''; if (differences.length === 0) return '';
const materialOnly = differences.find( const normalizedMaterial = String(materialCode || '')
.trim()
.toLowerCase();
const filtered = differences.filter((entry) => {
const normalized = String(entry || '')
.trim()
.toLowerCase();
const isMaterialOnly = !normalized.includes(':');
return !(isMaterialOnly && normalized === normalizedMaterial);
});
if (filtered.length === 0) {
return '';
}
const materialOnly = filtered.find(
(entry) => !entry.includes(':') && entry.trim().length > 0, (entry) => !entry.includes(':') && entry.trim().length > 0,
); );
return materialOnly || differences.join(' | '); return materialOnly || filtered.join(' | ');
} }
} }

View File

@@ -13,11 +13,9 @@
> >
</app-stl-viewer> </app-stl-viewer>
} }
<!-- Close button removed as requested -->
</div> </div>
} }
<!-- Initial Dropzone (Visible only when no files) -->
@if (items().length === 0) { @if (items().length === 0) {
<app-dropzone <app-dropzone
[label]="'CALC.UPLOAD_LABEL'" [label]="'CALC.UPLOAD_LABEL'"
@@ -29,7 +27,6 @@
</app-dropzone> </app-dropzone>
} }
<!-- New File List with Details -->
@if (items().length > 0) { @if (items().length > 0) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) { @for (item of items(); track item.file.name; let i = $index) {
@@ -52,7 +49,7 @@
type="number" type="number"
min="1" min="1"
[value]="item.quantity" [value]="item.quantity"
(change)="updateItemQuantity(i, $event)" (input)="updateItemQuantity(i, $event)"
class="qty-input" class="qty-input"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
/> />
@@ -83,7 +80,6 @@
} }
</div> </div>
<!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container"> <div class="add-more-container">
<input <input
#additionalInput #additionalInput
@@ -111,39 +107,58 @@
>. >.
</p> </p>
<label class="item-settings-checkbox item-settings-checkbox--top"> @if (mode() === "easy") {
<input <div class="easy-global-controls">
type="checkbox" <label class="easy-global-field">
[checked]="sameSettingsForAll()" <span>{{ "CALC.MATERIAL" | translate }}</span>
(change)="onSameSettingsToggle($any($event.target).checked)" <select formControlName="material">
/> @for (mat of materials(); track mat.value) {
<span>Tutti i file uguali (applica impostazioni a tutti)</span> <option [value]="mat.value">{{ mat.label }}</option>
</label> }
</select>
</label>
@if (sameSettingsForAll()) { <label class="easy-global-field">
<div class="item-settings-panel"> <span>{{ "CALC.QUALITY" | translate }}</span>
<h4 class="item-settings-title">Impostazioni globali</h4> <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">
<label class="sync-settings-toggle">
<input
type="checkbox"
[checked]="sameSettingsForAll()"
[disabled]="lockedSettings()"
(change)="onSameSettingsToggle($any($event.target).checked)"
/>
<span class="sync-settings-copy">
<span class="sync-settings-title">
Stesse impostazioni per tutti i file
</span>
<span class="sync-settings-subtitle">Colore escluso</span>
</span>
</label>
</div>
<div class="item-settings-grid"> @if (sameSettingsForAll()) {
<label> <div class="item-settings-panel">
{{ "CALC.MATERIAL" | translate }} <h4 class="item-settings-title">Impostazioni globali</h4>
<select formControlName="material">
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
@if (mode() === "easy") { <div class="item-settings-grid">
<label> <label>
{{ "CALC.QUALITY" | translate }} {{ "CALC.MATERIAL" | translate }}
<select formControlName="quality"> <select formControlName="material">
@for (quality of qualities(); track quality.value) { @for (mat of materials(); track mat.value) {
<option [value]="quality.value">{{ quality.label }}</option> <option [value]="mat.value">{{ mat.label }}</option>
} }
</select> </select>
</label> </label>
} @else {
<label> <label>
{{ "CALC.NOZZLE" | translate }} {{ "CALC.NOZZLE" | translate }}
<select formControlName="nozzleDiameter"> <select formControlName="nozzleDiameter">
@@ -152,10 +167,8 @@
} }
</select> </select>
</label> </label>
} </div>
</div>
@if (mode() === "advanced") {
<div class="item-settings-grid"> <div class="item-settings-grid">
<label> <label>
{{ "CALC.PATTERN" | translate }} {{ "CALC.PATTERN" | translate }}
@@ -179,7 +192,12 @@
<div class="item-settings-grid"> <div class="item-settings-grid">
<label> <label>
{{ "CALC.INFILL" | translate }} {{ "CALC.INFILL" | translate }}
<input type="number" min="0" max="100" formControlName="infillDensity" /> <input
type="number"
min="0"
max="100"
formControlName="infillDensity"
/>
</label> </label>
<label class="item-settings-checkbox"> <label class="item-settings-checkbox">
@@ -187,161 +205,77 @@
<span>{{ "CALC.SUPPORT" | translate }}</span> <span>{{ "CALC.SUPPORT" | translate }}</span>
</label> </label>
</div> </div>
}
</div>
} @else {
@if (getSelectedItem(); as selectedItem) {
<div class="item-settings-panel">
<h4 class="item-settings-title">
Impostazioni file: {{ selectedItem.file.name }}
</h4>
<div class="item-settings-grid">
<label>
{{ "CALC.MATERIAL" | translate }}
<select
[value]="selectedItem.material || form.get('material')?.value"
(change)="
updateItemMaterial(getSelectedItemIndex(), $any($event.target).value)
"
>
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
@if (mode() === "easy") {
<label>
{{ "CALC.QUALITY" | translate }}
<select
[value]="selectedItem.quality || form.get('quality')?.value"
(change)="
updateSelectedItemStringField(
'quality',
$any($event.target).value
)
"
>
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
} @else {
<label>
{{ "CALC.NOZZLE" | translate }}
<select
[value]="
selectedItem.nozzleDiameter ?? form.get('nozzleDiameter')?.value
"
(change)="
updateSelectedItemNumberField(
'nozzleDiameter',
+$any($event.target).value
)
"
>
@for (n of nozzleDiameters(); track n.value) {
<option [value]="n.value">{{ n.label }}</option>
}
</select>
</label>
}
</div> </div>
@if (mode() === "easy") { } @else {
<app-select @if (getSelectedItem(); as selectedItem) {
formControlName="quality" <div class="item-settings-panel">
[label]="'CALC.QUALITY' | translate" <h4 class="item-settings-title">
[options]="qualities()" Impostazioni file: {{ selectedItem.file.name }}
></app-select> </h4>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
}
</div>
@if (items().length > 1) { <div class="item-settings-grid">
<div class="checkbox-row sync-all-row"> <label>
<input type="checkbox" formControlName="syncAllItems" id="syncAllItems" /> {{ "CALC.MATERIAL" | translate }}
<label for="syncAllItems"> <select formControlName="material">
Uguale per tutti i pezzi @for (mat of materials(); track mat.value) {
</label> <option [value]="mat.value">{{ mat.label }}</option>
</div> }
} </select>
</label>
@if (mode() === "advanced") { <label>
<div class="item-settings-grid"> {{ "CALC.NOZZLE" | translate }}
<label> <select formControlName="nozzleDiameter">
{{ "CALC.PATTERN" | translate }} @for (n of nozzleDiameters(); track n.value) {
<select <option [value]="n.value">{{ n.label }}</option>
[value]="selectedItem.infillPattern || form.get('infillPattern')?.value" }
(change)=" </select>
updateSelectedItemStringField( </label>
'infillPattern', </div>
$any($event.target).value
)
"
>
@for (p of infillPatterns(); track p.value) {
<option [value]="p.value">{{ p.label }}</option>
}
</select>
</label>
<label> <div class="item-settings-grid">
{{ "CALC.LAYER_HEIGHT" | translate }} <label>
<select {{ "CALC.PATTERN" | translate }}
[value]="selectedItem.layerHeight ?? form.get('layerHeight')?.value" <select formControlName="infillPattern">
(change)=" @for (p of infillPatterns(); track p.value) {
updateSelectedItemNumberField( <option [value]="p.value">{{ p.label }}</option>
'layerHeight', }
+$any($event.target).value </select>
) </label>
"
>
@for (l of layerHeights(); track l.value) {
<option [value]="l.value">{{ l.label }}</option>
}
</select>
</label>
</div>
<div class="item-settings-grid"> <label>
<label> {{ "CALC.LAYER_HEIGHT" | translate }}
{{ "CALC.INFILL" | translate }} <select formControlName="layerHeight">
<input @for (
type="number" l of getLayerHeightOptionsForNozzle(
min="0" form.get("nozzleDiameter")?.value
max="100" );
[value]=" track l.value
selectedItem.infillDensity ?? form.get('infillDensity')?.value ) {
" <option [value]="l.value">{{ l.label }}</option>
(change)=" }
updateSelectedItemNumberField( </select>
'infillDensity', </label>
+$any($event.target).value </div>
)
"
/>
</label>
<label class="item-settings-checkbox"> <div class="item-settings-grid">
<input <label>
type="checkbox" {{ "CALC.INFILL" | translate }}
[checked]=" <input
selectedItem.supportEnabled ?? form.get('supportEnabled')?.value type="number"
" min="0"
(change)="updateSelectedItemSupport($any($event.target).checked)" max="100"
/> formControlName="infillDensity"
<span>{{ "CALC.SUPPORT" | translate }}</span> />
</label> </label>
<label class="item-settings-checkbox">
<input type="checkbox" formControlName="supportEnabled" />
<span>{{ "CALC.SUPPORT" | translate }}</span>
</label>
</div>
</div> </div>
} }
</div>
} }
} }
} }
@@ -349,7 +283,6 @@
@if (items().length === 0 && form.get("itemsTouched")?.value) { @if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div> <div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
} }
</div> </div>
<app-input <app-input
@@ -359,7 +292,6 @@
></app-input> ></app-input>
<div class="actions"> <div class="actions">
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
@if (loading() && uploadProgress() < 100) { @if (loading() && uploadProgress() < 100) {
<div class="progress-container"> <div class="progress-container">
<div class="progress-bar"> <div class="progress-bar">

View File

@@ -2,8 +2,8 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.upload-privacy-note { .upload-privacy-note {
margin-top: var(--space-6); margin-top: var(--space-4);
margin-bottom: 0; margin-bottom: var(--space-1);
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: left; text-align: left;
@@ -35,48 +35,50 @@
/* Grid Layout for Files */ /* Grid Layout for Files */
.items-grid { .items-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */ grid-template-columns: 1fr;
gap: var(--space-2); /* Tighten gap for mobile */ gap: var(--space-3);
margin-top: var(--space-4); margin-top: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@media (min-width: 640px) { @media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
gap: var(--space-3); gap: var(--space-3);
} }
} }
.file-card { .file-card {
padding: var(--space-2); /* Reduced from space-3 */ padding: var(--space-3);
background: var(--color-neutral-100); background: var(--color-bg-card);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; /* Reduced gap */ gap: var(--space-2);
position: relative; /* For absolute positioning of remove btn */ position: relative;
min-width: 0; /* Allow flex item to shrink below content size if needed */ min-width: 0;
&:hover { &:hover {
border-color: var(--color-neutral-300); border-color: var(--color-neutral-300);
box-shadow: 0 4px 10px rgba(10, 20, 30, 0.07);
} }
&.active { &.active {
border-color: var(--color-brand); border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05); background: rgba(250, 207, 10, 0.08);
box-shadow: 0 0 0 1px var(--color-brand); box-shadow: 0 0 0 1px var(--color-brand);
} }
} }
.card-header { .card-header {
overflow: hidden; overflow: hidden;
padding-right: 25px; /* Adjusted */ padding-right: 28px;
margin-bottom: 2px; margin-bottom: 0;
} }
.file-name { .file-name {
font-weight: 500; font-weight: 600;
font-size: 0.8rem; /* Smaller font */ font-size: 0.92rem;
color: var(--color-text); color: var(--color-text);
display: block; display: block;
white-space: nowrap; white-space: nowrap;
@@ -92,47 +94,46 @@
.card-controls { .card-controls {
display: flex; display: flex;
align-items: flex-end; /* Align bottom of input and color circle */ align-items: flex-end;
gap: 16px; /* Space between Qty and Color */ gap: var(--space-4);
width: 100%; width: 100%;
} }
.qty-group, .qty-group,
.color-group { .color-group {
display: flex; display: flex;
flex-direction: column; /* Stack label and input */ flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0px; gap: 2px;
label { label {
font-size: 0.6rem; font-size: 0.72rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.3px;
font-weight: 600; font-weight: 600;
margin-bottom: 2px; margin-bottom: 0;
} }
} }
.color-group { .color-group {
align-items: flex-start; /* Align label left */ align-items: flex-start;
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container { ::ng-deep .color-selector-container {
margin-left: 0; margin-left: 0;
} }
} }
.qty-input { .qty-input {
width: 36px; /* Slightly smaller */ width: 54px;
padding: 1px 2px; padding: 4px 6px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-align: center; text-align: center;
font-size: 0.85rem; font-size: 0.95rem;
font-weight: 600;
background: white; background: white;
height: 24px; /* Explicit height to match color circle somewhat */ height: 34px;
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--color-brand); border-color: var(--color-brand);
@@ -141,10 +142,10 @@
.btn-remove { .btn-remove {
position: absolute; position: absolute;
top: 4px; top: 6px;
right: 4px; right: 6px;
width: 18px; width: 20px;
height: 18px; height: 20px;
border-radius: 4px; border-radius: 4px;
border: none; border: none;
background: transparent; background: transparent;
@@ -155,7 +156,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.8rem; font-size: 0.9rem;
&:hover { &:hover {
background: var(--color-danger-100); background: var(--color-danger-100);
@@ -170,7 +171,7 @@
.btn-add-more { .btn-add-more {
width: 100%; width: 100%;
padding: var(--space-3); padding: 0.75rem var(--space-3);
background: var(--color-neutral-800); background: var(--color-neutral-800);
color: white; color: white;
border: none; border: none;
@@ -193,6 +194,92 @@
} }
} }
.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 {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-50);
padding: var(--space-3);
}
.sync-settings-toggle {
display: flex;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
input[type="checkbox"] {
width: 20px;
height: 20px;
margin-top: 2px;
accent-color: var(--color-brand);
flex-shrink: 0;
}
}
.sync-settings-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.sync-settings-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
.sync-settings-subtitle {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
line-height: 1.35;
}
.checkbox-row { .checkbox-row {
display: flex; display: flex;
align-items: center; align-items: center;

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;
@@ -127,16 +129,22 @@ export class QuoteEstimatorService {
getOptions(): Observable<OptionsResponse> { getOptions(): Observable<OptionsResponse> {
const headers: any = {}; const headers: any = {};
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { return this.http.get<OptionsResponse>(
headers, `${environment.apiUrl}/api/calculator/options`,
}); {
headers,
},
);
} }
getQuoteSession(sessionId: string): Observable<any> { getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { return this.http.get(
headers, `${environment.apiUrl}/api/quote-sessions/${sessionId}`,
}); {
headers,
},
);
} }
updateLineItem(lineItemId: string, changes: any): Observable<any> { updateLineItem(lineItemId: string, changes: any): Observable<any> {
@@ -175,10 +183,13 @@ export class QuoteEstimatorService {
getOrderInvoice(orderId: string): Observable<Blob> { getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, { return this.http.get(
headers, `${environment.apiUrl}/api/orders/${orderId}/invoice`,
responseType: 'blob', {
}); headers,
responseType: 'blob',
},
);
} }
getOrderConfirmation(orderId: string): Observable<Blob> { getOrderConfirmation(orderId: string): Observable<Blob> {
@@ -226,7 +237,8 @@ export class QuoteEstimatorService {
const emitProgress = () => { const emitProgress = () => {
const avg = Math.round( const avg = Math.round(
uploadProgress.reduce((sum, value) => sum + value, 0) / totalItems, uploadProgress.reduce((sum, value) => sum + value, 0) /
totalItems,
); );
observer.next(avg); observer.next(avg);
}; };
@@ -239,7 +251,9 @@ export class QuoteEstimatorService {
const hasFailure = uploadResults.some((entry) => !entry.success); const hasFailure = uploadResults.some((entry) => !entry.success);
if (hasFailure) { if (hasFailure) {
observer.error('One or more files failed during upload/analysis'); observer.error(
'One or more files failed during upload/analysis',
);
return; return;
} }
@@ -370,9 +384,12 @@ 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 {
@@ -399,7 +416,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),
@@ -412,8 +433,13 @@ export class QuoteEstimatorService {
}; };
} }
private buildSettingsPayload(request: QuoteRequest, item: QuoteRequestItem): any { private buildSettingsPayload(
const normalizedQuality = this.normalizeQuality(item.quality || request.quality); request: QuoteRequest,
item: QuoteRequestItem,
): any {
const normalizedQuality = this.normalizeQuality(
item.quality || request.quality,
);
const easyPreset = const easyPreset =
request.mode === 'easy' request.mode === 'easy'
? this.buildEasyModePreset(normalizedQuality) ? this.buildEasyModePreset(normalizedQuality)
@@ -421,13 +447,17 @@ export class QuoteEstimatorService {
return { return {
complexityMode: request.mode === 'easy' ? 'BASIC' : 'ADVANCED', complexityMode: request.mode === 'easy' ? 'BASIC' : 'ADVANCED',
quantity: this.normalizeQuantity(item.quantity),
material: String(item.material || request.material || 'PLA'), material: String(item.material || request.material || 'PLA'),
color: item.color || '#FFFFFF', color: item.color || '#FFFFFF',
filamentVariantId: item.filamentVariantId, filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : normalizedQuality, quality: easyPreset ? easyPreset.quality : normalizedQuality,
supportsEnabled: item.supportEnabled ?? request.supportEnabled ?? false, supportsEnabled: item.supportEnabled ?? request.supportEnabled ?? false,
layerHeight: layerHeight:
easyPreset?.layerHeight ?? item.layerHeight ?? request.layerHeight ?? 0.2, easyPreset?.layerHeight ??
item.layerHeight ??
request.layerHeight ??
0.2,
infillDensity: infillDensity:
easyPreset?.infillDensity ?? easyPreset?.infillDensity ??
item.infillDensity ?? item.infillDensity ??
@@ -446,8 +476,18 @@ export class QuoteEstimatorService {
}; };
} }
private normalizeQuantity(value: number | undefined): number {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 1) {
return 1;
}
return Math.floor(numeric);
}
private normalizeQuality(value: string | undefined): string { private normalizeQuality(value: string | undefined): string {
const normalized = String(value || 'standard').trim().toLowerCase(); const normalized = String(value || 'standard')
.trim()
.toLowerCase();
if (normalized === 'high' || normalized === 'high_definition') { if (normalized === 'high' || normalized === 'high_definition') {
return 'extra_fine'; return 'extra_fine';
} }

View File

@@ -249,17 +249,22 @@
{{ "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 |
{{ item.materialGrams | number: "1.0-0" }}g {{ item.materialGrams | number: "1.0-0" }}g
</div> </div>
<div class="item-preview" *ngIf="isCadSession() && isStlItem(item)"> <div
class="item-preview"
*ngIf="isCadSession() && isStlItem(item)"
>
<ng-container <ng-container
*ngIf="previewFile(item) as itemPreview; else previewState" *ngIf="previewFile(item) as itemPreview; else previewState"
> >
@@ -318,24 +323,13 @@
</div> </div>
</div> </div>
<div class="summary-totals" *ngIf="quoteSession() as session"> <app-price-breakdown
<div class="total-row"> *ngIf="quoteSession() as session"
<span>{{ "CHECKOUT.SUBTOTAL" | translate }}</span> [rows]="checkoutPriceBreakdownRows(session)"
<span>{{ session.itemsTotalChf | currency: "CHF" }}</span> [total]="session.grandTotalChf || 0"
</div> [currency]="'CHF'"
<div class="total-row"> [totalLabelKey]="'CHECKOUT.TOTAL'"
<span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span> ></app-price-breakdown>
<span>{{ session.session.setupCostChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ "CHECKOUT.SHIPPING" | translate }}</span>
<span>{{ session.shippingCostChf | currency: "CHF" }}</span>
</div>
<div class="grand-total">
<span>{{ "CHECKOUT.TOTAL" | translate }}</span>
<span>{{ session.grandTotalChf | currency: "CHF" }}</span>
</div>
</div>
</app-card> </app-card>
</div> </div>
</div> </div>

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);
} }
} }
@@ -343,32 +355,6 @@ app-toggle-selector.user-type-selector-compact {
padding-right: var(--space-3); padding-right: var(--space-3);
} }
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-4);
border-radius: var(--radius-md);
margin-top: var(--space-6);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text);
}
.grand-total {
display: flex;
justify-content: space-between;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 2px solid var(--color-border);
}
}
.actions { .actions {
margin-top: var(--space-8); margin-top: var(--space-8);

View File

@@ -16,8 +16,13 @@ import {
AppToggleSelectorComponent, AppToggleSelectorComponent,
ToggleOption, ToggleOption,
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; } from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import {
PriceBreakdownComponent,
PriceBreakdownRow,
} from '../../shared/components/price-breakdown/price-breakdown.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',
@@ -30,6 +35,7 @@ import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewe
AppButtonComponent, AppButtonComponent,
AppCardComponent, AppCardComponent,
AppToggleSelectorComponent, AppToggleSelectorComponent,
PriceBreakdownComponent,
StlViewerComponent, StlViewerComponent,
], ],
templateUrl: './checkout.component.html', templateUrl: './checkout.component.html',
@@ -55,6 +61,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 +136,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) {
@@ -192,6 +202,29 @@ export class CheckoutComponent implements OnInit {
return this.quoteSession()?.cadTotalChf ?? 0; return this.quoteSession()?.cadTotalChf ?? 0;
} }
checkoutPriceBreakdownRows(session: any): PriceBreakdownRow[] {
return [
{
labelKey: 'CHECKOUT.SUBTOTAL',
amount: session?.itemsTotalChf ?? 0,
},
{
labelKey: 'CHECKOUT.SETUP_FEE',
amount:
session?.baseSetupCostChf ?? session?.session?.setupCostChf ?? 0,
},
{
label: 'Cambio Ugello',
amount: session?.nozzleChangeCostChf ?? 0,
visible: (session?.nozzleChangeCostChf ?? 0) > 0,
},
{
labelKey: 'CHECKOUT.SHIPPING',
amount: session?.shippingCostChf ?? 0,
},
];
}
itemMaterial(item: any): string { itemMaterial(item: any): string {
return String( return String(
item?.materialCode ?? this.quoteSession()?.session?.materialCode ?? '-', item?.materialCode ?? this.quoteSession()?.session?.materialCode ?? '-',
@@ -212,8 +245,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 +315,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 ||

View File

@@ -193,28 +193,12 @@
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p> <p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div> </div>
<div class="summary-totals"> <app-price-breakdown
<div class="total-row"> [rows]="orderPriceBreakdownRows(o)"
<span>{{ "PAYMENT.SUBTOTAL" | translate }}</span> [total]="o.totalChf || 0"
<span>{{ o.subtotalChf | currency: "CHF" }}</span> [currency]="'CHF'"
</div> [totalLabelKey]="'PAYMENT.TOTAL'"
<div class="total-row" *ngIf="o.cadTotalChf > 0"> ></app-price-breakdown>
<span>Servizio CAD ({{ o.cadHours || 0 }}h)</span>
<span>{{ o.cadTotalChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ "PAYMENT.SHIPPING" | translate }}</span>
<span>{{ o.shippingCostChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ "PAYMENT.SETUP_FEE" | translate }}</span>
<span>{{ o.setupCostChf | currency: "CHF" }}</span>
</div>
<div class="grand-total-row">
<span>{{ "PAYMENT.TOTAL" | translate }}</span>
<span>{{ o.totalChf | currency: "CHF" }}</span>
</div>
</div>
</app-card> </app-card>
</div> </div>
</div> </div>

View File

@@ -184,31 +184,6 @@
top: var(--space-6); top: var(--space-6);
} }
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-6);
border-radius: var(--radius-md);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 2px solid var(--color-border);
}
}
.actions { .actions {
margin-top: var(--space-8); margin-top: var(--space-8);
} }

View File

@@ -6,6 +6,10 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import {
PriceBreakdownComponent,
PriceBreakdownRow,
} from '../../shared/components/price-breakdown/price-breakdown.component';
@Component({ @Component({
selector: 'app-order', selector: 'app-order',
@@ -15,6 +19,7 @@ import { environment } from '../../../environments/environment';
AppButtonComponent, AppButtonComponent,
AppCardComponent, AppCardComponent,
TranslateModule, TranslateModule,
PriceBreakdownComponent,
], ],
templateUrl: './order.component.html', templateUrl: './order.component.html',
styleUrl: './order.component.scss', styleUrl: './order.component.scss',
@@ -171,6 +176,28 @@ export class OrderComponent implements OnInit {
return this.translate.instant('ORDER.NOT_AVAILABLE'); return this.translate.instant('ORDER.NOT_AVAILABLE');
} }
orderPriceBreakdownRows(order: any): PriceBreakdownRow[] {
return [
{
labelKey: 'PAYMENT.SUBTOTAL',
amount: order?.subtotalChf ?? 0,
},
{
label: `Servizio CAD (${order?.cadHours || 0}h)`,
amount: order?.cadTotalChf ?? 0,
visible: (order?.cadTotalChf ?? 0) > 0,
},
{
labelKey: 'PAYMENT.SHIPPING',
amount: order?.shippingCostChf ?? 0,
},
{
labelKey: 'PAYMENT.SETUP_FEE',
amount: order?.setupCostChf ?? 0,
},
];
}
private extractOrderNumber(orderId: string): string { private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0]; return orderId.split('-')[0];
} }

View File

@@ -0,0 +1,25 @@
<div class="price-breakdown">
<div class="price-row" *ngFor="let row of visibleRows()">
<span>
<ng-container *ngIf="row.label; else translatedRowLabel">
{{ row.label }}
</ng-container>
<ng-template #translatedRowLabel>
{{ row.labelKey ? (row.labelKey | translate) : "" }}
</ng-template>
</span>
<span>{{ row.amount | currency: currency() }}</span>
</div>
<div class="price-total">
<span>
<ng-container *ngIf="totalLabel(); else translatedTotalLabel">
{{ totalLabel() }}
</ng-container>
<ng-template #translatedTotalLabel>
{{ totalLabelKey() ? (totalLabelKey() | translate) : "" }}
</ng-template>
</span>
<span>{{ total() | currency: currency() }}{{ totalSuffix() }}</span>
</div>
</div>

View File

@@ -0,0 +1,31 @@
.price-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);
}
.price-row,
.price-total {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.price-row {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: var(--space-2);
}
.price-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

@@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { Component, computed, input } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
export interface PriceBreakdownRow {
label?: string;
labelKey?: string;
amount: number;
visible?: boolean;
}
@Component({
selector: 'app-price-breakdown',
standalone: true,
imports: [CommonModule, TranslateModule],
templateUrl: './price-breakdown.component.html',
styleUrl: './price-breakdown.component.scss',
})
export class PriceBreakdownComponent {
rows = input<PriceBreakdownRow[]>([]);
total = input.required<number>();
currency = input<string>('CHF');
totalSuffix = input<string>('');
totalLabel = input<string>('');
totalLabelKey = input<string>('');
visibleRows = computed(() =>
this.rows().filter((row) => row.visible === undefined || row.visible),
);
}

View File

@@ -90,7 +90,7 @@
"PROCESSING": "Verarbeitung...", "PROCESSING": "Verarbeitung...",
"NOTES_PLACEHOLDER": "Spezifische Anweisungen...", "NOTES_PLACEHOLDER": "Spezifische Anweisungen...",
"SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten", "SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten",
"SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet", "SHIPPING_NOTE": "* Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet",
"ERROR_ZERO_PRICE": "Bei der Berechnung ist etwas schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns direkt über \"Beratung anfragen\".", "ERROR_ZERO_PRICE": "Bei der Berechnung ist etwas schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns direkt über \"Beratung anfragen\".",
"ZERO_RESULT_TITLE": "Ungültiges Ergebnis", "ZERO_RESULT_TITLE": "Ungültiges Ergebnis",
"ZERO_RESULT_HELP": "Die Berechnung hat ungültige Werte (0) geliefert. Versuche ein anderes Dateiformat oder kontaktiere uns direkt über \"Beratung anfragen\"." "ZERO_RESULT_HELP": "Die Berechnung hat ungültige Werte (0) geliefert. Versuche ein anderes Dateiformat oder kontaktiere uns direkt über \"Beratung anfragen\"."

View File

@@ -90,7 +90,7 @@
"PROCESSING": "Processing...", "PROCESSING": "Processing...",
"NOTES_PLACEHOLDER": "Specific instructions...", "NOTES_PLACEHOLDER": "Specific instructions...",
"SETUP_NOTE": "* Includes {{cost}} as setup cost", "SETUP_NOTE": "* Includes {{cost}} as setup cost",
"SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step", "SHIPPING_NOTE": "* Shipping costs excluded, calculated at the next step",
"ERROR_ZERO_PRICE": "Something went wrong during the calculation. Try another format or contact us directly via Request Consultation.", "ERROR_ZERO_PRICE": "Something went wrong during the calculation. Try another format or contact us directly via Request Consultation.",
"ZERO_RESULT_TITLE": "Invalid Result", "ZERO_RESULT_TITLE": "Invalid Result",
"ZERO_RESULT_HELP": "The calculation returned invalid zero values. Try another file format or contact us directly via Request Consultation." "ZERO_RESULT_HELP": "The calculation returned invalid zero values. Try another file format or contact us directly via Request Consultation."

View File

@@ -110,7 +110,7 @@
"PROCESSING": "Traitement...", "PROCESSING": "Traitement...",
"NOTES_PLACEHOLDER": "Instructions spécifiques...", "NOTES_PLACEHOLDER": "Instructions spécifiques...",
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup", "SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
"SHIPPING_NOTE": "** Frais d'expédition exclus, calculés à l'étape suivante", "SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante",
"STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF", "STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF",
"REMOVE_FILE": "Supprimer le fichier", "REMOVE_FILE": "Supprimer le fichier",
"FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_MATERIAL": "PLA (fallback)",

View File

@@ -110,7 +110,7 @@
"PROCESSING": "Elaborazione...", "PROCESSING": "Elaborazione...",
"NOTES_PLACEHOLDER": "Istruzioni specifiche...", "NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} come costo di setup", "SETUP_NOTE": "* Include {{cost}} come costo di setup",
"SHIPPING_NOTE": "** Costi di spedizione esclusi, calcolati al passaggio successivo", "SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo",
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf", "STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf",
"REMOVE_FILE": "Rimuovi file", "REMOVE_FILE": "Rimuovi file",
"FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_MATERIAL": "PLA (fallback)",