2 Commits

Author SHA1 Message Date
8f2d21c0e1 Merge branch 'dev' into feat/calculator-options
Some checks failed
PR Checks / test-backend (pull_request) Failing after 20s
PR Checks / prettier-autofix (pull_request) Failing after 6s
PR Checks / security-sast (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Failing after 55s
2026-03-05 17:12:36 +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
12 changed files with 1644 additions and 1958 deletions

View File

@@ -1,42 +1,99 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.CreateOrderRequest; import com.printcalculator.dto.*;
import com.printcalculator.dto.OrderDto; import com.printcalculator.entity.*;
import com.printcalculator.service.order.OrderControllerService; import com.printcalculator.repository.*;
import jakarta.validation.Valid; import com.printcalculator.service.payment.InvoicePdfRenderingService;
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.GetMapping; import org.springframework.web.bind.annotation.*;
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.util.Map;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.Map;
import java.util.HashMap;
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 OrderControllerService orderControllerService; private final OrderService orderService;
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) {
this.orderControllerService = orderControllerService; public OrderController(OrderService orderService,
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 CreateOrderRequest request @Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request
) { ) {
return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request)); Order order = orderService.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)
@@ -46,17 +103,44 @@ public class OrderController {
@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);
if (!uploaded) { OrderItem item = orderItemRepo.findById(orderItemId)
.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 orderControllerService.getOrder(orderId) return orderRepo.findById(orderId)
.map(ResponseEntity::ok) .map(o -> {
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
return ResponseEntity.ok(convertToDto(o, items));
})
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@@ -66,29 +150,89 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody Map<String, String> payload @RequestBody Map<String, String> payload
) { ) {
return orderControllerService.reportPayment(orderId, payload.get("method")) String method = payload.get("method");
.map(ResponseEntity::ok) paymentService.reportPayment(orderId, method);
.orElse(ResponseEntity.notFound().build()); return getOrder(orderId);
} }
@GetMapping("/{orderId}/confirmation") @GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return orderControllerService.getConfirmation(orderId); return generateDocument(orderId, true);
} }
@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) {
return orderControllerService.getTwintPayment(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);
} }
@GetMapping("/{orderId}/twint/open") @GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
return orderControllerService.openTwintPayment(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();
} }
@GetMapping("/{orderId}/twint/qr") @GetMapping("/{orderId}/twint/qr")
@@ -96,6 +240,150 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size @RequestParam(defaultValue = "320") int size
) { ) {
return orderControllerService.getTwintQr(orderId, 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 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

@@ -1,9 +1,25 @@
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.service.order.AdminOrderControllerService; import com.printcalculator.dto.OrderItemDto;
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;
@@ -12,30 +28,80 @@ 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 AdminOrderControllerService adminOrderControllerService; 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 AdminOrderController(AdminOrderControllerService adminOrderControllerService) { public AdminOrderController(
this.adminOrderControllerService = 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;
} }
@GetMapping @GetMapping
public ResponseEntity<List<OrderDto>> listOrders() { public ResponseEntity<List<OrderDto>> listOrders() {
return ResponseEntity.ok(adminOrderControllerService.listOrders()); List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.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(adminOrderControllerService.getOrder(orderId)); return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
} }
@PostMapping("/{orderId}/payments/confirm") @PostMapping("/{orderId}/payments/confirm")
@@ -44,7 +110,13 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload @RequestBody(required = false) Map<String, String> payload
) { ) {
return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, 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 ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
} }
@PostMapping("/{orderId}/status") @PostMapping("/{orderId}/status")
@@ -53,7 +125,28 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload @RequestBody AdminOrderStatusUpdateRequest payload
) { ) {
return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload)); if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
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")
@@ -61,16 +154,290 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@PathVariable UUID orderItemId @PathVariable UUID orderItemId
) { ) {
return adminOrderControllerService.downloadOrderItemFile(orderId, 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 = 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 adminOrderControllerService.downloadOrderConfirmation(orderId); return generateDocument(getOrderOrThrow(orderId), true);
} }
@GetMapping("/{orderId}/documents/invoice") @GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return adminOrderControllerService.downloadOrderInvoice(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(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

@@ -1,423 +0,0 @@
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());
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

@@ -1,352 +0,0 @@
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());
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

@@ -2,12 +2,14 @@ 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;
@@ -39,6 +41,12 @@ 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;
@@ -55,10 +63,13 @@ class OrderControllerPrivacyTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
OrderControllerService orderControllerService = new OrderControllerService( controller = new OrderController(
orderService, orderService,
orderRepo, orderRepo,
orderItemRepo, orderItemRepo,
quoteSessionRepo,
quoteLineItemRepo,
customerRepo,
storageService, storageService,
invoiceService, invoiceService,
qrBillService, qrBillService,
@@ -66,7 +77,6 @@ class OrderControllerPrivacyTest {
paymentService, paymentService,
paymentRepo paymentRepo
); );
controller = new OrderController(orderControllerService);
} }
@Test @Test

View File

@@ -6,8 +6,6 @@ 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;
@@ -43,8 +41,6 @@ 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;
@@ -59,18 +55,16 @@ class AdminOrderControllerStatusValidationTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
AdminOrderControllerService adminOrderControllerService = new AdminOrderControllerService( controller = new AdminOrderController(
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

@@ -63,11 +63,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 |
<span class="material-chip">{{ item.material || "N/D" }}</span> materiale: {{ item.material || "N/D" }}
@if (getItemDifferenceLabel(item.fileName, item.material)) { @if (getItemDifferenceLabel(item.fileName)) {
| |
<small class="item-settings-diff"> <small class="item-settings-diff">
{{ getItemDifferenceLabel(item.fileName, item.material) }} {{ getItemDifferenceLabel(item.fileName) }}
</small> </small>
} }
</span> </span>
@@ -110,7 +110,7 @@
<div class="actions"> <div class="actions">
<div class="actions-left"> <div class="actions-left">
<app-button variant="secondary" (click)="consult.emit()"> <app-button variant="outline" (click)="consult.emit()">
{{ "QUOTE.CONSULT" | translate }} {{ "QUOTE.CONSULT" | translate }}
</app-button> </app-button>
</div> </div>

View File

@@ -20,11 +20,10 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--space-3) var(--space-4); padding: var(--space-3);
background: var(--color-bg-card); background: var(--color-neutral-50);
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 {
@@ -55,19 +54,6 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.material-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid #d9d4bd;
background: #fbf7e9;
color: #6d5b1d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.2px;
}
.item-controls { .item-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -163,7 +149,6 @@
.actions-right { .actions-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2);
} }
.actions-right { .actions-right {

View File

@@ -189,30 +189,14 @@ export class QuoteResultComponent implements OnDestroy {
this.quantityTimers.clear(); this.quantityTimers.clear();
} }
getItemDifferenceLabel(fileName: string, materialCode?: string): string { 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 normalizedMaterial = String(materialCode || '') const materialOnly = differences.find(
.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 || filtered.join(' | '); return materialOnly || differences.join(' | ');
} }
} }

View File

@@ -13,9 +13,11 @@
> >
</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'"
@@ -27,6 +29,7 @@
</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) {
@@ -80,6 +83,7 @@
} }
</div> </div>
<!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container"> <div class="add-more-container">
<input <input
#additionalInput #additionalInput
@@ -107,23 +111,14 @@
>. >.
</p> </p>
@if (mode() === "advanced") { <label class="item-settings-checkbox item-settings-checkbox--top">
<div class="sync-settings">
<label class="sync-settings-toggle">
<input <input
type="checkbox" type="checkbox"
[checked]="sameSettingsForAll()" [checked]="sameSettingsForAll()"
[disabled]="lockedSettings()"
(change)="onSameSettingsToggle($any($event.target).checked)" (change)="onSameSettingsToggle($any($event.target).checked)"
/> />
<span class="sync-settings-copy"> <span>Tutti i file uguali (applica impostazioni a tutti)</span>
<span class="sync-settings-title">
Stesse impostazioni per tutti i file
</span>
<span class="sync-settings-subtitle">Colore escluso</span>
</span>
</label> </label>
</div>
@if (sameSettingsForAll()) { @if (sameSettingsForAll()) {
<div class="item-settings-panel"> <div class="item-settings-panel">
@@ -139,6 +134,16 @@
</select> </select>
</label> </label>
@if (mode() === "easy") {
<label>
{{ "CALC.QUALITY" | translate }}
<select formControlName="quality">
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
} @else {
<label> <label>
{{ "CALC.NOZZLE" | translate }} {{ "CALC.NOZZLE" | translate }}
<select formControlName="nozzleDiameter"> <select formControlName="nozzleDiameter">
@@ -147,8 +152,10 @@
} }
</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 }}
@@ -172,12 +179,7 @@
<div class="item-settings-grid"> <div class="item-settings-grid">
<label> <label>
{{ "CALC.INFILL" | translate }} {{ "CALC.INFILL" | translate }}
<input <input type="number" min="0" max="100" formControlName="infillDensity" />
type="number"
min="0"
max="100"
formControlName="infillDensity"
/>
</label> </label>
<label class="item-settings-checkbox"> <label class="item-settings-checkbox">
@@ -185,6 +187,7 @@
<span>{{ "CALC.SUPPORT" | translate }}</span> <span>{{ "CALC.SUPPORT" | translate }}</span>
</label> </label>
</div> </div>
}
</div> </div>
} @else { } @else {
@if (getSelectedItem(); as selectedItem) { @if (getSelectedItem(); as selectedItem) {
@@ -196,27 +199,93 @@
<div class="item-settings-grid"> <div class="item-settings-grid">
<label> <label>
{{ "CALC.MATERIAL" | translate }} {{ "CALC.MATERIAL" | translate }}
<select formControlName="material"> <select
[value]="selectedItem.material || form.get('material')?.value"
(change)="
updateItemMaterial(getSelectedItemIndex(), $any($event.target).value)
"
>
@for (mat of materials(); track mat.value) { @for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option> <option [value]="mat.value">{{ mat.label }}</option>
} }
</select> </select>
</label> </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> <label>
{{ "CALC.NOZZLE" | translate }} {{ "CALC.NOZZLE" | translate }}
<select formControlName="nozzleDiameter"> <select
[value]="
selectedItem.nozzleDiameter ?? form.get('nozzleDiameter')?.value
"
(change)="
updateSelectedItemNumberField(
'nozzleDiameter',
+$any($event.target).value
)
"
>
@for (n of nozzleDiameters(); track n.value) { @for (n of nozzleDiameters(); track n.value) {
<option [value]="n.value">{{ n.label }}</option> <option [value]="n.value">{{ n.label }}</option>
} }
</select> </select>
</label> </label>
}
</div>
@if (mode() === "easy") {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
}
</div> </div>
@if (items().length > 1) {
<div class="checkbox-row sync-all-row">
<input type="checkbox" formControlName="syncAllItems" id="syncAllItems" />
<label for="syncAllItems">
Uguale per tutti i pezzi
</label>
</div>
}
@if (mode() === "advanced") {
<div class="item-settings-grid"> <div class="item-settings-grid">
<label> <label>
{{ "CALC.PATTERN" | translate }} {{ "CALC.PATTERN" | translate }}
<select formControlName="infillPattern"> <select
[value]="selectedItem.infillPattern || form.get('infillPattern')?.value"
(change)="
updateSelectedItemStringField(
'infillPattern',
$any($event.target).value
)
"
>
@for (p of infillPatterns(); track p.value) { @for (p of infillPatterns(); track p.value) {
<option [value]="p.value">{{ p.label }}</option> <option [value]="p.value">{{ p.label }}</option>
} }
@@ -225,8 +294,16 @@
<label> <label>
{{ "CALC.LAYER_HEIGHT" | translate }} {{ "CALC.LAYER_HEIGHT" | translate }}
<select formControlName="layerHeight"> <select
@for (l of getLayerHeightOptionsForNozzle(form.get('nozzleDiameter')?.value); track l.value) { [value]="selectedItem.layerHeight ?? form.get('layerHeight')?.value"
(change)="
updateSelectedItemNumberField(
'layerHeight',
+$any($event.target).value
)
"
>
@for (l of layerHeights(); track l.value) {
<option [value]="l.value">{{ l.label }}</option> <option [value]="l.value">{{ l.label }}</option>
} }
</select> </select>
@@ -240,17 +317,31 @@
type="number" type="number"
min="0" min="0"
max="100" max="100"
formControlName="infillDensity" [value]="
selectedItem.infillDensity ?? form.get('infillDensity')?.value
"
(change)="
updateSelectedItemNumberField(
'infillDensity',
+$any($event.target).value
)
"
/> />
</label> </label>
<label class="item-settings-checkbox"> <label class="item-settings-checkbox">
<input type="checkbox" formControlName="supportEnabled" /> <input
type="checkbox"
[checked]="
selectedItem.supportEnabled ?? form.get('supportEnabled')?.value
"
(change)="updateSelectedItemSupport($any($event.target).checked)"
/>
<span>{{ "CALC.SUPPORT" | translate }}</span> <span>{{ "CALC.SUPPORT" | translate }}</span>
</label> </label>
</div> </div>
</div>
} }
</div>
} }
} }
} }
@@ -258,6 +349,7 @@
@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
@@ -267,6 +359,7 @@
></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-4); margin-top: var(--space-6);
margin-bottom: var(--space-1); margin-bottom: 0;
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,50 +35,48 @@
/* Grid Layout for Files */ /* Grid Layout for Files */
.items-grid { .items-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-3); gap: var(--space-2); /* Tighten gap for mobile */
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-3); padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-bg-card); background: var(--color-neutral-100);
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: var(--space-2); gap: 4px; /* Reduced gap */
position: relative; position: relative; /* For absolute positioning of remove btn */
min-width: 0; min-width: 0; /* Allow flex item to shrink below content size if needed */
&: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.08); background: rgba(250, 207, 10, 0.05);
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: 28px; padding-right: 25px; /* Adjusted */
margin-bottom: 0; margin-bottom: 2px;
} }
.file-name { .file-name {
font-weight: 600; font-weight: 500;
font-size: 0.92rem; font-size: 0.8rem; /* Smaller font */
color: var(--color-text); color: var(--color-text);
display: block; display: block;
white-space: nowrap; white-space: nowrap;
@@ -94,46 +92,47 @@
.card-controls { .card-controls {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end; /* Align bottom of input and color circle */
gap: var(--space-4); gap: 16px; /* Space between Qty and Color */
width: 100%; width: 100%;
} }
.qty-group, .qty-group,
.color-group { .color-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column; /* Stack label and input */
align-items: flex-start; align-items: flex-start;
gap: 2px; gap: 0px;
label { label {
font-size: 0.72rem; font-size: 0.6rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.5px;
font-weight: 600; font-weight: 600;
margin-bottom: 0; margin-bottom: 2px;
} }
} }
.color-group { .color-group {
align-items: flex-start; align-items: flex-start; /* Align label left */
/* 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: 54px; width: 36px; /* Slightly smaller */
padding: 4px 6px; padding: 1px 2px;
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.95rem; font-size: 0.85rem;
font-weight: 600;
background: white; background: white;
height: 34px; height: 24px; /* Explicit height to match color circle somewhat */
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--color-brand); border-color: var(--color-brand);
@@ -142,10 +141,10 @@
.btn-remove { .btn-remove {
position: absolute; position: absolute;
top: 6px; top: 4px;
right: 6px; right: 4px;
width: 20px; width: 18px;
height: 20px; height: 18px;
border-radius: 4px; border-radius: 4px;
border: none; border: none;
background: transparent; background: transparent;
@@ -156,7 +155,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.9rem; font-size: 0.8rem;
&:hover { &:hover {
background: var(--color-danger-100); background: var(--color-danger-100);
@@ -171,7 +170,7 @@
.btn-add-more { .btn-add-more {
width: 100%; width: 100%;
padding: 0.75rem var(--space-3); padding: var(--space-3);
background: var(--color-neutral-800); background: var(--color-neutral-800);
color: white; color: white;
border: none; border: none;
@@ -194,50 +193,6 @@
} }
} }
.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;