Compare commits
1 Commits
8f2d21c0e1
...
a7491130fb
| Author | SHA1 | Date | |
|---|---|---|---|
| a7491130fb |
@@ -1,99 +1,42 @@
|
|||||||
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)
|
||||||
@@ -103,44 +46,17 @@ 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);
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
materiale: {{ item.material || "N/D" }}
|
<span class="material-chip">{{ item.material || "N/D" }}</span>
|
||||||
@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>
|
||||||
@@ -110,7 +110,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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -54,6 +55,19 @@
|
|||||||
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;
|
||||||
@@ -149,6 +163,7 @@
|
|||||||
.actions-right {
|
.actions-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-right {
|
.actions-right {
|
||||||
|
|||||||
@@ -189,14 +189,30 @@ export class QuoteResultComponent implements OnDestroy {
|
|||||||
this.quantityTimers.clear();
|
this.quantityTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemDifferenceLabel(fileName: string): string {
|
getItemDifferenceLabel(fileName: string, materialCode?: 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(' | ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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,14 +107,23 @@
|
|||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="item-settings-checkbox item-settings-checkbox--top">
|
@if (mode() === "advanced") {
|
||||||
|
<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>Tutti i file uguali (applica impostazioni a tutti)</span>
|
<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>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (sameSettingsForAll()) {
|
@if (sameSettingsForAll()) {
|
||||||
<div class="item-settings-panel">
|
<div class="item-settings-panel">
|
||||||
@@ -134,16 +139,6 @@
|
|||||||
</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">
|
||||||
@@ -152,10 +147,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 +172,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,7 +185,6 @@
|
|||||||
<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) {
|
||||||
@@ -199,93 +196,27 @@
|
|||||||
<div class="item-settings-grid">
|
<div class="item-settings-grid">
|
||||||
<label>
|
<label>
|
||||||
{{ "CALC.MATERIAL" | translate }}
|
{{ "CALC.MATERIAL" | translate }}
|
||||||
<select
|
<select formControlName="material">
|
||||||
[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
|
<select formControlName="nozzleDiameter">
|
||||||
[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
|
<select formControlName="infillPattern">
|
||||||
[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>
|
||||||
}
|
}
|
||||||
@@ -294,16 +225,8 @@
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
{{ "CALC.LAYER_HEIGHT" | translate }}
|
{{ "CALC.LAYER_HEIGHT" | translate }}
|
||||||
<select
|
<select formControlName="layerHeight">
|
||||||
[value]="selectedItem.layerHeight ?? form.get('layerHeight')?.value"
|
@for (l of getLayerHeightOptionsForNozzle(form.get('nozzleDiameter')?.value); track l.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>
|
||||||
@@ -317,39 +240,24 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
[value]="
|
formControlName="infillDensity"
|
||||||
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
|
<input type="checkbox" formControlName="supportEnabled" />
|
||||||
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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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 +267,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">
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user