Compare commits
45 Commits
4e99d12be1
...
not-workin
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f73924572 | |||
| 8edc4af645 | |||
| e1409d218b | |||
| 0c92f8b394 | |||
| 66de93a315 | |||
| b337db03c4 | |||
| 8c6c1e10b3 | |||
| eac3006512 | |||
| b26b582baf | |||
| 875c6ffd2d | |||
| 579ac3fcb6 | |||
| efa1371ffa | |||
| ab7f263aca | |||
| 49bae8e186 | |||
| e2872c730c | |||
| 86266b31ee | |||
| 5d0fb5fe6d | |||
| 91af8f4f9c | |||
| a96c28fb39 | |||
| 9b24ca529c | |||
| 6216d9a723 | |||
| 4aa3f6adf1 | |||
| 7baad738f5 | |||
| 9feceb9b3c | |||
| 304ed942b8 | |||
| 881bd87392 | |||
| 3a5e4e3427 | |||
| 8c82470401 | |||
| ef6a5278a7 | |||
| bb276b6504 | |||
| e351f2c05f | |||
| 165e12f216 | |||
| 475bfcc6fb | |||
| becb15da73 | |||
| 4d559901eb | |||
| 06a036810a | |||
| 0b29aebfcf | |||
| 961109b04c | |||
| b5bd68ed10 | |||
| 56fb504062 | |||
| f165d191be | |||
| e1d9823b51 | |||
| f829ccef4a | |||
| 59e881c3f4 | |||
| f5aa0f298e |
@@ -21,16 +21,6 @@ jobs:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Run Tests with Gradle
|
||||
run: |
|
||||
cd backend
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN ./gradlew bootJar -x test --no-daemon
|
||||
# Stage 2: Runtime Environment
|
||||
FROM eclipse-temurin:21-jre-jammy
|
||||
|
||||
# Install system dependencies for OrcaSlicer (same as before)
|
||||
# Install system dependencies for OrcaSlicer
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
p7zip-full \
|
||||
@@ -20,6 +20,14 @@ RUN apt-get update && apt-get install -y \
|
||||
libgtk-3-0 \
|
||||
libdbus-1-3 \
|
||||
libwebkit2gtk-4.0-37 \
|
||||
libx11-xcb1 \
|
||||
libxcb-dri3-0 \
|
||||
libxtst6 \
|
||||
libnss3 \
|
||||
libatk-bridge2.0-0 \
|
||||
libxss1 \
|
||||
libasound2 \
|
||||
libgbm1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install OrcaSlicer
|
||||
|
||||
@@ -25,12 +25,21 @@ repositories {
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'xyz.capybara:clamav-client:2.1.2'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'com.h2database:h2'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37'
|
||||
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
|
||||
@@ -26,13 +26,14 @@ public class CustomQuoteRequestController {
|
||||
private final CustomQuoteRequestRepository requestRepo;
|
||||
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
|
||||
|
||||
// TODO: Inject Storage Service
|
||||
private static final String STORAGE_ROOT = "storage_requests";
|
||||
private final com.printcalculator.service.StorageService storageService;
|
||||
|
||||
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
||||
CustomQuoteRequestAttachmentRepository attachmentRepo) {
|
||||
CustomQuoteRequestAttachmentRepository attachmentRepo,
|
||||
com.printcalculator.service.StorageService storageService) {
|
||||
this.requestRepo = requestRepo;
|
||||
this.attachmentRepo = attachmentRepo;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
// 1. Create Custom Quote Request
|
||||
@@ -91,10 +92,8 @@ public class CustomQuoteRequestController {
|
||||
attachment.setStoredRelativePath(relativePath);
|
||||
attachmentRepo.save(attachment);
|
||||
|
||||
// Save file to disk
|
||||
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
||||
Files.createDirectories(absolutePath.getParent());
|
||||
Files.copy(file.getInputStream(), absolutePath);
|
||||
// Save file to disk via StorageService
|
||||
storageService.store(file, Paths.get(relativePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,20 @@ public class OptionsController {
|
||||
.filter(m -> m != null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Sort: PLA first, then PETG, then others alphabetically
|
||||
materialOptions.sort((a, b) -> {
|
||||
String codeA = a.code();
|
||||
String codeB = b.code();
|
||||
|
||||
if (codeA.equals("pla_basic")) return -1;
|
||||
if (codeB.equals("pla_basic")) return 1;
|
||||
|
||||
if (codeA.equals("petg_basic")) return -1;
|
||||
if (codeB.equals("petg_basic")) return 1;
|
||||
|
||||
return codeA.compareTo(codeB);
|
||||
});
|
||||
|
||||
// 2. Qualities (Static as per user request)
|
||||
List<OptionsResponse.QualityOption> qualities = List.of(
|
||||
new OptionsResponse.QualityOption("draft", "Draft"),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.*;
|
||||
import com.printcalculator.entity.*;
|
||||
import com.printcalculator.repository.*;
|
||||
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||
import com.printcalculator.service.OrderService;
|
||||
import com.printcalculator.service.QrBillService;
|
||||
import com.printcalculator.service.StorageService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
@@ -15,200 +19,61 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.Optional;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderItemRepository orderItemRepo;
|
||||
private final QuoteSessionRepository quoteSessionRepo;
|
||||
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||
private final CustomerRepository customerRepo;
|
||||
private final StorageService storageService;
|
||||
private final InvoicePdfRenderingService invoiceService;
|
||||
private final QrBillService qrBillService;
|
||||
|
||||
// TODO: Inject Storage Service or use a base path property
|
||||
private static final String STORAGE_ROOT = "storage_orders";
|
||||
|
||||
public OrderController(OrderRepository orderRepo,
|
||||
public OrderController(OrderService orderService,
|
||||
OrderRepository orderRepo,
|
||||
OrderItemRepository orderItemRepo,
|
||||
QuoteSessionRepository quoteSessionRepo,
|
||||
QuoteLineItemRepository quoteLineItemRepo,
|
||||
CustomerRepository customerRepo) {
|
||||
CustomerRepository customerRepo,
|
||||
StorageService storageService,
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// 1. Create Order from Quote
|
||||
@PostMapping("/from-quote/{quoteSessionId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Order> createOrderFromQuote(
|
||||
public ResponseEntity<OrderDto> createOrderFromQuote(
|
||||
@PathVariable UUID quoteSessionId,
|
||||
@RequestBody com.printcalculator.dto.CreateOrderRequest request
|
||||
) {
|
||||
// 1. Fetch Quote Session
|
||||
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
|
||||
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
|
||||
|
||||
if (!"ACTIVE".equals(session.getStatus())) {
|
||||
// Allow converting only active sessions? Or check if not already converted?
|
||||
// checking convertedOrderId might be better
|
||||
}
|
||||
if (session.getConvertedOrderId() != null) {
|
||||
return ResponseEntity.badRequest().body(null); // Already converted
|
||||
Order order = orderService.createOrderFromQuote(quoteSessionId, request);
|
||||
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
|
||||
return ResponseEntity.ok(convertToDto(order, items));
|
||||
}
|
||||
|
||||
// 2. Handle Customer (Find or Create)
|
||||
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
|
||||
.orElseGet(() -> {
|
||||
Customer newC = new Customer();
|
||||
newC.setEmail(request.getCustomer().getEmail());
|
||||
newC.setCreatedAt(OffsetDateTime.now());
|
||||
return customerRepo.save(newC);
|
||||
});
|
||||
// Update customer details?
|
||||
customer.setPhone(request.getCustomer().getPhone());
|
||||
customer.setCustomerType(request.getCustomer().getCustomerType());
|
||||
customer.setUpdatedAt(OffsetDateTime.now());
|
||||
customerRepo.save(customer);
|
||||
|
||||
// 3. Create Order
|
||||
Order order = new Order();
|
||||
order.setSourceQuoteSession(session);
|
||||
order.setCustomer(customer);
|
||||
order.setCustomerEmail(request.getCustomer().getEmail());
|
||||
order.setCustomerPhone(request.getCustomer().getPhone());
|
||||
order.setStatus("PENDING_PAYMENT");
|
||||
order.setCreatedAt(OffsetDateTime.now());
|
||||
order.setUpdatedAt(OffsetDateTime.now());
|
||||
order.setCurrency("CHF");
|
||||
|
||||
// Billing
|
||||
order.setBillingCustomerType(request.getCustomer().getCustomerType());
|
||||
if (request.getBillingAddress() != null) {
|
||||
order.setBillingFirstName(request.getBillingAddress().getFirstName());
|
||||
order.setBillingLastName(request.getBillingAddress().getLastName());
|
||||
order.setBillingCompanyName(request.getBillingAddress().getCompanyName());
|
||||
order.setBillingContactPerson(request.getBillingAddress().getContactPerson());
|
||||
order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1());
|
||||
order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2());
|
||||
order.setBillingZip(request.getBillingAddress().getZip());
|
||||
order.setBillingCity(request.getBillingAddress().getCity());
|
||||
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
|
||||
}
|
||||
|
||||
// Shipping
|
||||
order.setShippingSameAsBilling(request.isShippingSameAsBilling());
|
||||
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
|
||||
order.setShippingFirstName(request.getShippingAddress().getFirstName());
|
||||
order.setShippingLastName(request.getShippingAddress().getLastName());
|
||||
order.setShippingCompanyName(request.getShippingAddress().getCompanyName());
|
||||
order.setShippingContactPerson(request.getShippingAddress().getContactPerson());
|
||||
order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1());
|
||||
order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2());
|
||||
order.setShippingZip(request.getShippingAddress().getZip());
|
||||
order.setShippingCity(request.getShippingAddress().getCity());
|
||||
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
|
||||
} else {
|
||||
// Copy billing to shipping? Or leave empty and rely on flag?
|
||||
// Usually explicit copy is safer for queries
|
||||
order.setShippingFirstName(order.getBillingFirstName());
|
||||
order.setShippingLastName(order.getBillingLastName());
|
||||
order.setShippingCompanyName(order.getBillingCompanyName());
|
||||
order.setShippingContactPerson(order.getBillingContactPerson());
|
||||
order.setShippingAddressLine1(order.getBillingAddressLine1());
|
||||
order.setShippingAddressLine2(order.getBillingAddressLine2());
|
||||
order.setShippingZip(order.getBillingZip());
|
||||
order.setShippingCity(order.getBillingCity());
|
||||
order.setShippingCountryCode(order.getBillingCountryCode());
|
||||
}
|
||||
|
||||
// Financials from Session (Assuming mocked/calculated in session)
|
||||
// We re-calculate totals from line items to be safe
|
||||
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
|
||||
|
||||
BigDecimal subtotal = BigDecimal.ZERO;
|
||||
|
||||
// Save Order first to get ID
|
||||
order = orderRepo.save(order);
|
||||
|
||||
// 4. Create Order Items
|
||||
for (QuoteLineItem qItem : quoteItems) {
|
||||
OrderItem oItem = new OrderItem();
|
||||
oItem.setOrder(order);
|
||||
oItem.setOriginalFilename(qItem.getOriginalFilename());
|
||||
oItem.setQuantity(qItem.getQuantity());
|
||||
oItem.setColorCode(qItem.getColorCode());
|
||||
oItem.setMaterialCode(session.getMaterialCode()); // Or per item if supported
|
||||
|
||||
// Pricing
|
||||
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
||||
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
||||
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
||||
|
||||
// File Handling Check
|
||||
// "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}"
|
||||
UUID fileUuid = UUID.randomUUID();
|
||||
String ext = getExtension(qItem.getOriginalFilename());
|
||||
String storedFilename = fileUuid.toString() + "." + ext;
|
||||
|
||||
oItem.setStoredFilename(storedFilename);
|
||||
oItem.setStoredRelativePath("PENDING"); // Placeholder
|
||||
oItem.setMimeType("application/octet-stream"); // specific type if known
|
||||
|
||||
oItem = orderItemRepo.save(oItem);
|
||||
|
||||
// Update Path now that we have ID
|
||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||
oItem.setStoredRelativePath(relativePath);
|
||||
|
||||
// COPY FILE from Quote to Order
|
||||
if (qItem.getStoredPath() != null) {
|
||||
try {
|
||||
Path sourcePath = Paths.get(qItem.getStoredPath());
|
||||
if (Files.exists(sourcePath)) {
|
||||
Path targetPath = Paths.get(STORAGE_ROOT, relativePath);
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
oItem.setFileSizeBytes(Files.size(targetPath));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace(); // Log error but allow order creation? Or fail?
|
||||
// Ideally fail or mark as error
|
||||
}
|
||||
}
|
||||
|
||||
orderItemRepo.save(oItem);
|
||||
|
||||
subtotal = subtotal.add(oItem.getLineTotalChf());
|
||||
}
|
||||
|
||||
// Update Order Totals
|
||||
order.setSubtotalChf(subtotal);
|
||||
order.setSetupCostChf(session.getSetupCostChf());
|
||||
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
|
||||
// TODO: Calc implementation for shipping
|
||||
|
||||
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||
order.setTotalChf(total);
|
||||
|
||||
// Link session
|
||||
session.setConvertedOrderId(order.getId());
|
||||
session.setStatus("CONVERTED"); // or CLOSED
|
||||
quoteSessionRepo.save(session);
|
||||
|
||||
return ResponseEntity.ok(orderRepo.save(order));
|
||||
}
|
||||
|
||||
// 2. Upload file for Order Item
|
||||
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Transactional
|
||||
public ResponseEntity<Void> uploadOrderItemFile(
|
||||
@@ -224,39 +89,103 @@ public class OrderController {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
// Ensure path logic
|
||||
String relativePath = item.getStoredRelativePath();
|
||||
if (relativePath == null || relativePath.equals("PENDING")) {
|
||||
// Should verify consistency
|
||||
// If we used the logic above, it should have a path.
|
||||
// If it's "PENDING", regen it.
|
||||
String ext = getExtension(file.getOriginalFilename());
|
||||
String storedFilename = UUID.randomUUID().toString() + "." + ext;
|
||||
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
|
||||
item.setStoredRelativePath(relativePath);
|
||||
item.setStoredFilename(storedFilename);
|
||||
// Update item
|
||||
}
|
||||
|
||||
// Save file to disk
|
||||
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
||||
Files.createDirectories(absolutePath.getParent());
|
||||
|
||||
if (Files.exists(absolutePath)) {
|
||||
Files.delete(absolutePath); // Overwrite?
|
||||
}
|
||||
|
||||
Files.copy(file.getInputStream(), absolutePath);
|
||||
|
||||
storageService.store(file, Paths.get(relativePath));
|
||||
item.setFileSizeBytes(file.getSize());
|
||||
item.setMimeType(file.getContentType());
|
||||
// Calculate SHA256? (Optional)
|
||||
|
||||
orderItemRepo.save(item);
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}")
|
||||
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
|
||||
return orderRepo.findById(orderId)
|
||||
.map(o -> {
|
||||
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
|
||||
return ResponseEntity.ok(convertToDto(o, items));
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}/invoice")
|
||||
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
||||
Order order = orderRepo.findById(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("Order not found"));
|
||||
|
||||
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
|
||||
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("sellerDisplayName", "3D Fab Switzerland");
|
||||
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
|
||||
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||
vars.put("sellerEmail", "info@3dfab.ch");
|
||||
|
||||
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
||||
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
|
||||
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
|
||||
? order.getBillingCompanyName()
|
||||
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||
vars.put("buyerDisplayName", buyerName);
|
||||
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
||||
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
||||
|
||||
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
||||
Map<String, Object> line = new HashMap<>();
|
||||
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
||||
line.put("quantity", i.getQuantity());
|
||||
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
||||
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
||||
return line;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> setupLine = new HashMap<>();
|
||||
setupLine.put("description", "Costo Setup");
|
||||
setupLine.put("quantity", 1);
|
||||
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||
invoiceLineItems.add(setupLine);
|
||||
|
||||
Map<String, Object> shippingLine = new HashMap<>();
|
||||
shippingLine.put("description", "Spedizione");
|
||||
shippingLine.put("quantity", 1);
|
||||
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||
invoiceLineItems.add(shippingLine);
|
||||
|
||||
vars.put("invoiceLineItems", invoiceLineItems);
|
||||
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
||||
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
||||
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
|
||||
|
||||
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
|
||||
if (qrBillSvg.contains("<?xml")) {
|
||||
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
||||
if (svgStartIndex != -1) {
|
||||
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "stl";
|
||||
int i = filename.lastIndexOf('.');
|
||||
@@ -266,4 +195,64 @@ public class OrderController {
|
||||
return "stl";
|
||||
}
|
||||
|
||||
private OrderDto convertToDto(Order order, List<OrderItem> items) {
|
||||
OrderDto dto = new OrderDto();
|
||||
dto.setId(order.getId());
|
||||
dto.setStatus(order.getStatus());
|
||||
dto.setCustomerEmail(order.getCustomerEmail());
|
||||
dto.setCustomerPhone(order.getCustomerPhone());
|
||||
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.setTotalChf(order.getTotalChf());
|
||||
dto.setCreatedAt(order.getCreatedAt());
|
||||
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.exception.ModelTooLargeException;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.model.StlBounds;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.ProfileManager;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.StlService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -15,22 +19,29 @@ import java.util.HashMap;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@RestController
|
||||
public class QuoteController {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
|
||||
|
||||
private final SlicerService slicerService;
|
||||
private final StlService stlService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
private final ProfileManager profileManager;
|
||||
|
||||
// Defaults (using aliases defined in ProfileManager)
|
||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||
private static final String DEFAULT_PROCESS = "standard";
|
||||
|
||||
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
|
||||
public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
|
||||
this.slicerService = slicerService;
|
||||
this.stlService = stlService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
this.profileManager = profileManager;
|
||||
}
|
||||
|
||||
@PostMapping("/api/quote")
|
||||
@@ -44,7 +55,7 @@ public class QuoteController {
|
||||
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
|
||||
@RequestParam(value = "layer_height", required = false) Double layerHeight,
|
||||
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
|
||||
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
|
||||
@RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
|
||||
) throws IOException {
|
||||
|
||||
// ... process selection logic ...
|
||||
@@ -72,6 +83,9 @@ public class QuoteController {
|
||||
}
|
||||
if (supportEnabled != null) {
|
||||
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
|
||||
if (supportEnabled) {
|
||||
processOverrides.put("support_threshold_angle", "45");
|
||||
}
|
||||
}
|
||||
|
||||
if (nozzleDiameter != null) {
|
||||
@@ -81,7 +95,7 @@ public class QuoteController {
|
||||
// For now, we trust the override key works on the base profile.
|
||||
}
|
||||
|
||||
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
|
||||
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
|
||||
}
|
||||
|
||||
@PostMapping("/calculate/stl")
|
||||
@@ -89,12 +103,13 @@ public class QuoteController {
|
||||
@RequestParam("file") MultipartFile file
|
||||
) throws IOException {
|
||||
// Legacy endpoint uses defaults
|
||||
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
|
||||
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
|
||||
}
|
||||
|
||||
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
|
||||
Map<String, String> machineOverrides,
|
||||
Map<String, String> processOverrides) throws IOException {
|
||||
Map<String, String> processOverrides,
|
||||
Double nozzleDiameter) throws IOException {
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@@ -105,23 +120,74 @@ public class QuoteController {
|
||||
|
||||
// Save uploaded file temporarily
|
||||
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||
com.printcalculator.model.StlShiftResult shift = null;
|
||||
try {
|
||||
file.transferTo(tempInput.toFile());
|
||||
|
||||
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
|
||||
// Use profile from machine or fallback
|
||||
String slicerMachineProfile = machine.getSlicerMachineProfile();
|
||||
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
|
||||
slicerMachineProfile = "bambu_a1";
|
||||
}
|
||||
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
|
||||
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||
// Validate model size against machine volume
|
||||
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
|
||||
|
||||
// Auto-center if needed
|
||||
shift = stlService.shiftToFitIfNeeded(
|
||||
tempInput.toFile(),
|
||||
bounds,
|
||||
machine.getBuildVolumeXMm(),
|
||||
machine.getBuildVolumeYMm(),
|
||||
machine.getBuildVolumeZMm()
|
||||
);
|
||||
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
|
||||
if (shift.shifted()) {
|
||||
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
|
||||
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
|
||||
}
|
||||
|
||||
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||
|
||||
// Calculate Quote (Pass machine display name for pricing lookup)
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return ResponseEntity.internalServerError().build();
|
||||
} finally {
|
||||
Files.deleteIfExists(tempInput);
|
||||
if (shift != null && shift.shifted()) {
|
||||
try {
|
||||
Files.deleteIfExists(shift.shiftedPath());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
||||
StlBounds bounds = stlService.readBounds(stlFile);
|
||||
double x = bounds.sizeX();
|
||||
double y = bounds.sizeY();
|
||||
double z = bounds.sizeZ();
|
||||
|
||||
int bx = machine.getBuildVolumeXMm();
|
||||
int by = machine.getBuildVolumeYMm();
|
||||
int bz = machine.getBuildVolumeZMm();
|
||||
|
||||
logger.info(String.format(
|
||||
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
|
||||
bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
|
||||
x, y, z, bx, by, bz
|
||||
));
|
||||
|
||||
double eps = 0.01;
|
||||
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
||||
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
||||
|
||||
if (!fits) {
|
||||
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@ package com.printcalculator.controller;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.exception.ModelTooLargeException;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.model.StlBounds;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.ProfileManager;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.StlService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -28,18 +32,24 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/quote-sessions")
|
||||
|
||||
public class QuoteSessionController {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
|
||||
|
||||
private final QuoteSessionRepository sessionRepo;
|
||||
private final QuoteLineItemRepository lineItemRepo;
|
||||
private final SlicerService slicerService;
|
||||
private final StlService stlService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final ProfileManager profileManager;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||
private final com.printcalculator.service.StorageService storageService;
|
||||
|
||||
// Defaults
|
||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||
@@ -48,15 +58,21 @@ public class QuoteSessionController {
|
||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||
QuoteLineItemRepository lineItemRepo,
|
||||
SlicerService slicerService,
|
||||
StlService stlService,
|
||||
QuoteCalculator quoteCalculator,
|
||||
ProfileManager profileManager,
|
||||
PrinterMachineRepository machineRepo,
|
||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo) {
|
||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||
com.printcalculator.service.StorageService storageService) {
|
||||
this.sessionRepo = sessionRepo;
|
||||
this.lineItemRepo = lineItemRepo;
|
||||
this.slicerService = slicerService;
|
||||
this.stlService = stlService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.profileManager = profileManager;
|
||||
this.machineRepo = machineRepo;
|
||||
this.pricingRepo = pricingRepo;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
// 1. Start a new empty session
|
||||
@@ -100,9 +116,7 @@ public class QuoteSessionController {
|
||||
if (file.isEmpty()) throw new IOException("File is empty");
|
||||
|
||||
// 1. Define Persistent Storage Path
|
||||
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
|
||||
String storageDir = "storage_quotes/" + session.getId();
|
||||
Files.createDirectories(Paths.get(storageDir));
|
||||
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String ext = originalFilename != null && originalFilename.contains(".")
|
||||
@@ -110,11 +124,15 @@ public class QuoteSessionController {
|
||||
: ".stl";
|
||||
|
||||
String storedFilename = UUID.randomUUID() + ext;
|
||||
Path persistentPath = Paths.get(storageDir, storedFilename);
|
||||
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
|
||||
|
||||
// Save file
|
||||
Files.copy(file.getInputStream(), persistentPath);
|
||||
storageService.store(file, relativePath);
|
||||
|
||||
// Resolve absolute path for slicing and storage usage
|
||||
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
|
||||
|
||||
com.printcalculator.model.StlShiftResult shift = null;
|
||||
try {
|
||||
// Apply Basic/Advanced Logic
|
||||
applyPrintSettings(settings);
|
||||
@@ -124,12 +142,32 @@ public class QuoteSessionController {
|
||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||
|
||||
// 2. Pick Profiles
|
||||
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
||||
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
|
||||
// For now assuming display name works or we use a tough default
|
||||
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
|
||||
// Ideally: machine.getSlicerProfileName();
|
||||
// 2. Validate model size against machine volume
|
||||
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
|
||||
|
||||
// 2b. Auto-center if needed (keeps the stored STL unchanged)
|
||||
shift = stlService.shiftToFitIfNeeded(
|
||||
persistentPath.toFile(),
|
||||
bounds,
|
||||
machine.getBuildVolumeXMm(),
|
||||
machine.getBuildVolumeYMm(),
|
||||
machine.getBuildVolumeZMm()
|
||||
);
|
||||
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
|
||||
if (shift.shifted()) {
|
||||
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
|
||||
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
|
||||
}
|
||||
|
||||
// 3. Pick Profiles
|
||||
String machineProfile = machine.getSlicerMachineProfile();
|
||||
if (machineProfile == null || machineProfile.isBlank()) {
|
||||
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
||||
}
|
||||
if (machineProfile == null || machineProfile.isBlank()) {
|
||||
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
|
||||
}
|
||||
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
|
||||
|
||||
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
|
||||
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
|
||||
@@ -138,8 +176,25 @@ public class QuoteSessionController {
|
||||
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
|
||||
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
|
||||
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
|
||||
|
||||
// Update Session Material
|
||||
session.setMaterialCode(settings.getMaterial());
|
||||
} else {
|
||||
// Fallback if null?
|
||||
session.setMaterialCode("pla_basic");
|
||||
}
|
||||
|
||||
// Update Session Settings for Persistence
|
||||
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
|
||||
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
|
||||
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
|
||||
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
|
||||
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
|
||||
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
|
||||
|
||||
// Save session updates
|
||||
sessionRepo.save(session);
|
||||
|
||||
String processProfile = "0.20mm Standard @BBL A1";
|
||||
// Mapping quality to process
|
||||
// "standard" -> "0.20mm Standard @BBL A1"
|
||||
@@ -151,26 +206,40 @@ public class QuoteSessionController {
|
||||
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
|
||||
}
|
||||
|
||||
// Build overrides map from settings
|
||||
// Build overrides map from settings
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
||||
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||
if (settings.getSupportsEnabled() != null) {
|
||||
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
|
||||
// If enabled, use a more permissive threshold (45 deg) by default
|
||||
// to avoid expensive supports on things that don't strictly need them
|
||||
if (settings.getSupportsEnabled()) {
|
||||
processOverrides.put("support_threshold_angle", "45");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Slice (Use persistent path)
|
||||
Map<String, String> machineOverrides = new HashMap<>();
|
||||
if (settings.getNozzleDiameter() != null) {
|
||||
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
|
||||
}
|
||||
|
||||
// 4. Slice (Use persistent path)
|
||||
PrintStats stats = slicerService.slice(
|
||||
persistentPath.toFile(),
|
||||
sliceInput,
|
||||
machineProfile,
|
||||
filamentProfile,
|
||||
processProfile,
|
||||
null, // machine overrides
|
||||
machineOverrides, // machine overrides
|
||||
processOverrides
|
||||
);
|
||||
|
||||
// 4. Calculate Quote
|
||||
// 5. Calculate Quote
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||
|
||||
// 5. Create Line Item
|
||||
// 6. Create Line Item
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setOriginalFilename(file.getOriginalFilename());
|
||||
@@ -179,8 +248,8 @@ public class QuoteSessionController {
|
||||
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
|
||||
item.setStatus("READY"); // or CALCULATED
|
||||
|
||||
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
|
||||
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
|
||||
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
|
||||
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
|
||||
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
||||
|
||||
// Store breakdown
|
||||
@@ -190,14 +259,10 @@ public class QuoteSessionController {
|
||||
breakdown.put("setup_fee", result.getSetupCost());
|
||||
item.setPricingBreakdown(breakdown);
|
||||
|
||||
// Dimensions
|
||||
// Cannot get bb from GCodeParser yet?
|
||||
// If GCodeParser doesn't return size, we might defaults or 0.
|
||||
// Stats has filament used.
|
||||
// Let's set dummy for now or upgrade parser later.
|
||||
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
||||
// Dimensions from STL
|
||||
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
|
||||
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
|
||||
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
|
||||
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
@@ -206,10 +271,45 @@ public class QuoteSessionController {
|
||||
|
||||
} catch (Exception e) {
|
||||
// Cleanup if failed
|
||||
Files.deleteIfExists(persistentPath);
|
||||
try {
|
||||
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
|
||||
} catch (Exception ignored) {}
|
||||
throw e;
|
||||
} finally {
|
||||
if (shift != null && shift.shifted()) {
|
||||
try {
|
||||
Files.deleteIfExists(shift.shiftedPath());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
||||
StlBounds bounds = stlService.readBounds(stlFile);
|
||||
double x = bounds.sizeX();
|
||||
double y = bounds.sizeY();
|
||||
double z = bounds.sizeZ();
|
||||
|
||||
int bx = machine.getBuildVolumeXMm();
|
||||
int by = machine.getBuildVolumeYMm();
|
||||
int bz = machine.getBuildVolumeZMm();
|
||||
|
||||
logger.info(String.format(
|
||||
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
|
||||
bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
|
||||
x, y, z, bx, by, bz
|
||||
));
|
||||
|
||||
double eps = 0.01;
|
||||
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
||||
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
||||
|
||||
if (!fits) {
|
||||
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||
@@ -221,24 +321,30 @@ public class QuoteSessionController {
|
||||
settings.setLayerHeight(0.28);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
||||
break;
|
||||
case "high":
|
||||
settings.setLayerHeight(0.12);
|
||||
settings.setInfillDensity(20.0);
|
||||
settings.setInfillPattern("gyroid");
|
||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
||||
break;
|
||||
case "standard":
|
||||
default:
|
||||
settings.setLayerHeight(0.20);
|
||||
settings.setInfillDensity(20.0);
|
||||
settings.setInfillPattern("grid");
|
||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
||||
break;
|
||||
}
|
||||
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
|
||||
} else {
|
||||
// ADVANCED Mode: Use values from Frontend, set defaults if missing
|
||||
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
|
||||
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
|
||||
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
|
||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
||||
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +436,24 @@ public class QuoteSessionController {
|
||||
}
|
||||
|
||||
Path path = Paths.get(item.getStoredPath());
|
||||
// Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
|
||||
// But loadAsResource expects relative path?
|
||||
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
|
||||
// If path is absolute, resolve might fail or behave weirdly.
|
||||
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
|
||||
// If we want to use storageService.loadAsResource, we need the relative path.
|
||||
// Or we just access the file directly if we trust the absolute path.
|
||||
// But we want to use StorageService abstraction.
|
||||
|
||||
// Option 1: Reconstruct relative path.
|
||||
// We know structure: quotes/{sessionId}/{filename}...
|
||||
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
|
||||
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
|
||||
|
||||
// If we trust the file is on disk, we can use UrlResource directly here as before,
|
||||
// relying on the fact that storedPath is the absolute path to the file.
|
||||
// But we should verify it exists.
|
||||
|
||||
if (!Files.exists(path)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
74
backend/src/main/java/com/printcalculator/dto/OrderDto.java
Normal file
74
backend/src/main/java/com/printcalculator/dto/OrderDto.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class OrderDto {
|
||||
private UUID id;
|
||||
private String status;
|
||||
private String customerEmail;
|
||||
private String customerPhone;
|
||||
private String billingCustomerType;
|
||||
private AddressDto billingAddress;
|
||||
private AddressDto shippingAddress;
|
||||
private Boolean shippingSameAsBilling;
|
||||
private String currency;
|
||||
private BigDecimal setupCostChf;
|
||||
private BigDecimal shippingCostChf;
|
||||
private BigDecimal discountChf;
|
||||
private BigDecimal subtotalChf;
|
||||
private BigDecimal totalChf;
|
||||
private OffsetDateTime createdAt;
|
||||
private List<OrderItemDto> items;
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public String getCustomerEmail() { return customerEmail; }
|
||||
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
|
||||
|
||||
public String getCustomerPhone() { return customerPhone; }
|
||||
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
|
||||
|
||||
public String getBillingCustomerType() { return billingCustomerType; }
|
||||
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
|
||||
|
||||
public AddressDto getBillingAddress() { return billingAddress; }
|
||||
public void setBillingAddress(AddressDto billingAddress) { this.billingAddress = billingAddress; }
|
||||
|
||||
public AddressDto getShippingAddress() { return shippingAddress; }
|
||||
public void setShippingAddress(AddressDto shippingAddress) { this.shippingAddress = shippingAddress; }
|
||||
|
||||
public Boolean getShippingSameAsBilling() { return shippingSameAsBilling; }
|
||||
public void setShippingSameAsBilling(Boolean shippingSameAsBilling) { this.shippingSameAsBilling = shippingSameAsBilling; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public BigDecimal getSetupCostChf() { return setupCostChf; }
|
||||
public void setSetupCostChf(BigDecimal setupCostChf) { this.setupCostChf = setupCostChf; }
|
||||
|
||||
public BigDecimal getShippingCostChf() { return shippingCostChf; }
|
||||
public void setShippingCostChf(BigDecimal shippingCostChf) { this.shippingCostChf = shippingCostChf; }
|
||||
|
||||
public BigDecimal getDiscountChf() { return discountChf; }
|
||||
public void setDiscountChf(BigDecimal discountChf) { this.discountChf = discountChf; }
|
||||
|
||||
public BigDecimal getSubtotalChf() { return subtotalChf; }
|
||||
public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; }
|
||||
|
||||
public BigDecimal getTotalChf() { return totalChf; }
|
||||
public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public List<OrderItemDto> getItems() { return items; }
|
||||
public void setItems(List<OrderItemDto> items) { this.items = items; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public class OrderItemDto {
|
||||
private UUID id;
|
||||
private String originalFilename;
|
||||
private String materialCode;
|
||||
private String colorCode;
|
||||
private Integer quantity;
|
||||
private Integer printTimeSeconds;
|
||||
private BigDecimal materialGrams;
|
||||
private BigDecimal unitPriceChf;
|
||||
private BigDecimal lineTotalChf;
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public String getOriginalFilename() { return originalFilename; }
|
||||
public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; }
|
||||
|
||||
public String getMaterialCode() { return materialCode; }
|
||||
public void setMaterialCode(String materialCode) { this.materialCode = materialCode; }
|
||||
|
||||
public String getColorCode() { return colorCode; }
|
||||
public void setColorCode(String colorCode) { this.colorCode = colorCode; }
|
||||
|
||||
public Integer getQuantity() { return quantity; }
|
||||
public void setQuantity(Integer quantity) { this.quantity = quantity; }
|
||||
|
||||
public Integer getPrintTimeSeconds() { return printTimeSeconds; }
|
||||
public void setPrintTimeSeconds(Integer printTimeSeconds) { this.printTimeSeconds = printTimeSeconds; }
|
||||
|
||||
public BigDecimal getMaterialGrams() { return materialGrams; }
|
||||
public void setMaterialGrams(BigDecimal materialGrams) { this.materialGrams = materialGrams; }
|
||||
|
||||
public BigDecimal getUnitPriceChf() { return unitPriceChf; }
|
||||
public void setUnitPriceChf(BigDecimal unitPriceChf) { this.unitPriceChf = unitPriceChf; }
|
||||
|
||||
public BigDecimal getLineTotalChf() { return lineTotalChf; }
|
||||
public void setLineTotalChf(BigDecimal lineTotalChf) { this.lineTotalChf = lineTotalChf; }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public class PrintSettingsDto {
|
||||
private Double layerHeight;
|
||||
private Double infillDensity;
|
||||
private String infillPattern;
|
||||
private Boolean supportsEnabled;
|
||||
private Boolean supportsEnabled = true;
|
||||
private Double nozzleDiameter;
|
||||
private String notes;
|
||||
}
|
||||
|
||||
@@ -410,4 +410,5 @@ public class Order {
|
||||
this.paidAt = paidAt;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -41,6 +41,9 @@ public class PrinterMachine {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "slicer_machine_profile")
|
||||
private String slicerMachineProfile;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -57,6 +60,14 @@ public class PrinterMachine {
|
||||
this.printerDisplayName = printerDisplayName;
|
||||
}
|
||||
|
||||
public String getSlicerMachineProfile() {
|
||||
return slicerMachineProfile;
|
||||
}
|
||||
|
||||
public void setSlicerMachineProfile(String slicerMachineProfile) {
|
||||
this.slicerMachineProfile = slicerMachineProfile;
|
||||
}
|
||||
|
||||
public Integer getBuildVolumeXMm() {
|
||||
return buildVolumeXMm;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.printcalculator.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
@ControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(StorageException.class)
|
||||
public ResponseEntity<?> handleStorageException(StorageException exc) {
|
||||
// Log the full exception for internal debugging
|
||||
log.error("Storage Exception occurred", exc);
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
|
||||
// Check for specific virus case
|
||||
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
|
||||
response.put("error", "Security Violation");
|
||||
// Safe message for client
|
||||
response.put("message", "File rejected by security policy.");
|
||||
response.put("code", "VIRUS_DETECTED");
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
|
||||
}
|
||||
|
||||
// Generic fallback for other storage errors to avoid leaking internal paths/details
|
||||
response.put("error", "Storage Operation Failed");
|
||||
response.put("message", "Unable to process the file upload.");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("error", "File too large");
|
||||
response.put("message", "The uploaded file exceeds the maximum allowed size.");
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ModelTooLargeException.class)
|
||||
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("error", "Model too large");
|
||||
response.put("code", "MODEL_TOO_LARGE");
|
||||
response.put("message", String.format(
|
||||
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
|
||||
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
|
||||
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
|
||||
));
|
||||
response.put("model_x_mm", formatMm(exc.getModelX()));
|
||||
response.put("model_y_mm", formatMm(exc.getModelY()));
|
||||
response.put("model_z_mm", formatMm(exc.getModelZ()));
|
||||
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
|
||||
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
|
||||
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
|
||||
return ResponseEntity.unprocessableEntity().body(response);
|
||||
}
|
||||
|
||||
private String formatMm(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.printcalculator.exception;
|
||||
|
||||
public class ModelTooLargeException extends RuntimeException {
|
||||
private final double modelX;
|
||||
private final double modelY;
|
||||
private final double modelZ;
|
||||
private final int buildX;
|
||||
private final int buildY;
|
||||
private final int buildZ;
|
||||
|
||||
public ModelTooLargeException(double modelX, double modelY, double modelZ,
|
||||
int buildX, int buildY, int buildZ) {
|
||||
super("Model size exceeds build volume");
|
||||
this.modelX = modelX;
|
||||
this.modelY = modelY;
|
||||
this.modelZ = modelZ;
|
||||
this.buildX = buildX;
|
||||
this.buildY = buildY;
|
||||
this.buildZ = buildZ;
|
||||
}
|
||||
|
||||
public double getModelX() {
|
||||
return modelX;
|
||||
}
|
||||
|
||||
public double getModelY() {
|
||||
return modelY;
|
||||
}
|
||||
|
||||
public double getModelZ() {
|
||||
return modelZ;
|
||||
}
|
||||
|
||||
public int getBuildX() {
|
||||
return buildX;
|
||||
}
|
||||
|
||||
public int getBuildY() {
|
||||
return buildY;
|
||||
}
|
||||
|
||||
public int getBuildZ() {
|
||||
return buildZ;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.printcalculator.exception;
|
||||
|
||||
public class StorageException extends RuntimeException {
|
||||
|
||||
public StorageException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public StorageException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,29 @@
|
||||
package com.printcalculator.model;
|
||||
|
||||
public record PrintStats(
|
||||
long printTimeSeconds,
|
||||
String printTimeFormatted,
|
||||
double filamentWeightGrams,
|
||||
double filamentLengthMm
|
||||
) {}
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class PrintStats {
|
||||
private long printTimeSeconds;
|
||||
private String printTimeFormatted;
|
||||
private double filamentWeightGrams;
|
||||
private double filamentLengthMm;
|
||||
|
||||
// Breakdown if available
|
||||
private Double modelWeightGrams;
|
||||
private Double supportWeightGrams;
|
||||
|
||||
// Legacy constructor for compatibility
|
||||
public PrintStats(long printTimeSeconds, String printTimeFormatted, double filamentWeightGrams, double filamentLengthMm) {
|
||||
this.printTimeSeconds = printTimeSeconds;
|
||||
this.printTimeFormatted = printTimeFormatted;
|
||||
this.filamentWeightGrams = filamentWeightGrams;
|
||||
this.filamentLengthMm = filamentLengthMm;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.printcalculator.model;
|
||||
|
||||
public record StlBounds(double minX, double minY, double minZ,
|
||||
double maxX, double maxY, double maxZ) {
|
||||
public double sizeX() {
|
||||
return maxX - minX;
|
||||
}
|
||||
|
||||
public double sizeY() {
|
||||
return maxY - minY;
|
||||
}
|
||||
|
||||
public double sizeZ() {
|
||||
return maxZ - minZ;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.printcalculator.model;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public record StlShiftResult(Path shiftedPath,
|
||||
double offsetX,
|
||||
double offsetY,
|
||||
double offsetZ,
|
||||
boolean shifted) {
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
||||
import com.printcalculator.entity.OrderItem;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
|
||||
List<OrderItem> findByOrder_Id(UUID orderId);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import xyz.capybara.clamav.ClamavClient;
|
||||
import xyz.capybara.clamav.commands.scan.result.ScanResult;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ClamAVService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ClamAVService.class);
|
||||
|
||||
private final ClamavClient clamavClient;
|
||||
private final boolean enabled;
|
||||
|
||||
public ClamAVService(
|
||||
@Value("${clamav.host:clamav}") String host,
|
||||
@Value("${clamav.port:3310}") int port,
|
||||
@Value("${clamav.enabled:false}") boolean enabled
|
||||
) {
|
||||
this.enabled = enabled;
|
||||
if (!enabled) {
|
||||
logger.info("ClamAV is DISABLED");
|
||||
this.clamavClient = null;
|
||||
return;
|
||||
}
|
||||
logger.info("Initializing ClamAV client at {}:{}", host, port);
|
||||
ClamavClient client = null;
|
||||
try {
|
||||
client = new ClamavClient(host, port);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
|
||||
}
|
||||
this.clamavClient = client;
|
||||
}
|
||||
|
||||
public boolean scan(InputStream inputStream) {
|
||||
if (!enabled || clamavClient == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
ScanResult result = clamavClient.scan(inputStream);
|
||||
if (result instanceof ScanResult.OK) {
|
||||
return true;
|
||||
} else if (result instanceof ScanResult.VirusFound) {
|
||||
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
|
||||
logger.warn("VIRUS DETECTED: {}", viruses);
|
||||
return false;
|
||||
} else {
|
||||
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.printcalculator.exception.StorageException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@Service
|
||||
public class FileSystemStorageService implements StorageService {
|
||||
|
||||
private final Path rootLocation;
|
||||
private final ClamAVService clamAVService;
|
||||
|
||||
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
|
||||
this.rootLocation = Paths.get(storageLocation);
|
||||
this.clamAVService = clamAVService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
try {
|
||||
Files.createDirectories(rootLocation);
|
||||
} catch (IOException e) {
|
||||
throw new StorageException("Could not initialize storage", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
|
||||
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||
throw new StorageException("Cannot store file outside current directory.");
|
||||
}
|
||||
|
||||
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
file.transferTo(destinationFile.toFile());
|
||||
|
||||
// 2. Scansiona il file appena salvato aprendo un nuovo stream
|
||||
try (InputStream inputStream = new FileInputStream(destinationFile.toFile())) {
|
||||
if (!clamAVService.scan(inputStream)) {
|
||||
// Se infetto, cancella il file e solleva eccezione
|
||||
Files.deleteIfExists(destinationFile);
|
||||
throw new StorageException("File rejected by antivirus scanner.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof StorageException) throw e;
|
||||
// Se l'antivirus fallisce per motivi tecnici, lasciamo il file (fail-open come concordato)
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void store(Path source, Path destinationRelativePath) throws IOException {
|
||||
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||
throw new StorageException("Cannot store file outside current directory.");
|
||||
}
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Path path) throws IOException {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource loadAsResource(Path path) throws IOException {
|
||||
try {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Resource resource = new UrlResource(file.toUri());
|
||||
if (resource.exists() || resource.isReadable()) {
|
||||
return resource;
|
||||
} else {
|
||||
throw new RuntimeException("Could not read file: " + path);
|
||||
}
|
||||
} catch (MalformedURLException e) {
|
||||
throw new RuntimeException("Could not read file: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,15 @@ public class GCodeParser {
|
||||
private static final Pattern TIME_PATTERN = Pattern.compile(
|
||||
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
|
||||
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?");
|
||||
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
|
||||
|
||||
public PrintStats parse(File gcodeFile) throws IOException {
|
||||
long seconds = 0;
|
||||
double weightG = 0;
|
||||
double lengthMm = 0;
|
||||
Double modelWeightG = null;
|
||||
Double supportWeightG = null;
|
||||
String timeFormatted = "";
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
||||
@@ -78,7 +80,14 @@ public class GCodeParser {
|
||||
if (weightMatcher.find()) {
|
||||
try {
|
||||
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
||||
System.out.println("GCodeParser: Found weight: " + weightG + "g");
|
||||
System.out.println("GCodeParser: Found total weight: " + weightG + "g");
|
||||
|
||||
// Check if we have groups 2 and 3 for breakdown
|
||||
if (weightMatcher.groupCount() >= 3 && weightMatcher.group(2) != null) {
|
||||
modelWeightG = Double.parseDouble(weightMatcher.group(2).trim());
|
||||
supportWeightG = Double.parseDouble(weightMatcher.group(3).trim());
|
||||
System.out.println("GCodeParser: Found breakdown - Model: " + modelWeightG + "g, Support: " + supportWeightG + "g");
|
||||
}
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
@@ -92,7 +101,14 @@ public class GCodeParser {
|
||||
}
|
||||
}
|
||||
|
||||
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
|
||||
return PrintStats.builder()
|
||||
.printTimeSeconds(seconds)
|
||||
.printTimeFormatted(timeFormatted)
|
||||
.filamentWeightGrams(weightG)
|
||||
.filamentLengthMm(lengthMm)
|
||||
.modelWeightGrams(modelWeightG)
|
||||
.supportWeightGrams(supportWeightG)
|
||||
.build();
|
||||
}
|
||||
|
||||
private long parseTimeString(String timeStr) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class InvoicePdfRenderingService {
|
||||
|
||||
private final TemplateEngine thymeleafTemplateEngine;
|
||||
|
||||
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
|
||||
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
|
||||
}
|
||||
|
||||
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
|
||||
try {
|
||||
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
|
||||
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
|
||||
thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg);
|
||||
|
||||
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
|
||||
|
||||
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
|
||||
|
||||
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
|
||||
openHtmlToPdfRendererBuilder.useFastMode();
|
||||
openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer());
|
||||
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
|
||||
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
|
||||
openHtmlToPdfRendererBuilder.run();
|
||||
|
||||
return generatedPdfByteArrayOutputStream.toByteArray();
|
||||
} catch (Exception pdfGenerationException) {
|
||||
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.printcalculator.dto.AddressDto;
|
||||
import com.printcalculator.dto.CreateOrderRequest;
|
||||
import com.printcalculator.entity.*;
|
||||
import com.printcalculator.repository.CustomerRepository;
|
||||
import com.printcalculator.repository.OrderItemRepository;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderItemRepository orderItemRepo;
|
||||
private final QuoteSessionRepository quoteSessionRepo;
|
||||
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||
private final CustomerRepository customerRepo;
|
||||
private final StorageService storageService;
|
||||
private final InvoicePdfRenderingService invoiceService;
|
||||
private final QrBillService qrBillService;
|
||||
|
||||
public OrderService(OrderRepository orderRepo,
|
||||
OrderItemRepository orderItemRepo,
|
||||
QuoteSessionRepository quoteSessionRepo,
|
||||
QuoteLineItemRepository quoteLineItemRepo,
|
||||
CustomerRepository customerRepo,
|
||||
StorageService storageService,
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService) {
|
||||
this.orderRepo = orderRepo;
|
||||
this.orderItemRepo = orderItemRepo;
|
||||
this.quoteSessionRepo = quoteSessionRepo;
|
||||
this.quoteLineItemRepo = quoteLineItemRepo;
|
||||
this.customerRepo = customerRepo;
|
||||
this.storageService = storageService;
|
||||
this.invoiceService = invoiceService;
|
||||
this.qrBillService = qrBillService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
|
||||
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
|
||||
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
|
||||
|
||||
if (session.getConvertedOrderId() != null) {
|
||||
throw new IllegalStateException("Quote session already converted to order");
|
||||
}
|
||||
|
||||
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
|
||||
.orElseGet(() -> {
|
||||
Customer newC = new Customer();
|
||||
newC.setEmail(request.getCustomer().getEmail());
|
||||
newC.setCustomerType(request.getCustomer().getCustomerType());
|
||||
newC.setCreatedAt(OffsetDateTime.now());
|
||||
newC.setUpdatedAt(OffsetDateTime.now());
|
||||
return customerRepo.save(newC);
|
||||
});
|
||||
|
||||
customer.setPhone(request.getCustomer().getPhone());
|
||||
customer.setCustomerType(request.getCustomer().getCustomerType());
|
||||
customer.setUpdatedAt(OffsetDateTime.now());
|
||||
customerRepo.save(customer);
|
||||
|
||||
Order order = new Order();
|
||||
order.setSourceQuoteSession(session);
|
||||
order.setCustomer(customer);
|
||||
order.setCustomerEmail(request.getCustomer().getEmail());
|
||||
order.setCustomerPhone(request.getCustomer().getPhone());
|
||||
order.setStatus("PENDING_PAYMENT");
|
||||
order.setCreatedAt(OffsetDateTime.now());
|
||||
order.setUpdatedAt(OffsetDateTime.now());
|
||||
order.setCurrency("CHF");
|
||||
|
||||
order.setBillingCustomerType(request.getCustomer().getCustomerType());
|
||||
if (request.getBillingAddress() != null) {
|
||||
order.setBillingFirstName(request.getBillingAddress().getFirstName());
|
||||
order.setBillingLastName(request.getBillingAddress().getLastName());
|
||||
order.setBillingCompanyName(request.getBillingAddress().getCompanyName());
|
||||
order.setBillingContactPerson(request.getBillingAddress().getContactPerson());
|
||||
order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1());
|
||||
order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2());
|
||||
order.setBillingZip(request.getBillingAddress().getZip());
|
||||
order.setBillingCity(request.getBillingAddress().getCity());
|
||||
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
|
||||
}
|
||||
|
||||
order.setShippingSameAsBilling(request.isShippingSameAsBilling());
|
||||
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
|
||||
order.setShippingFirstName(request.getShippingAddress().getFirstName());
|
||||
order.setShippingLastName(request.getShippingAddress().getLastName());
|
||||
order.setShippingCompanyName(request.getShippingAddress().getCompanyName());
|
||||
order.setShippingContactPerson(request.getShippingAddress().getContactPerson());
|
||||
order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1());
|
||||
order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2());
|
||||
order.setShippingZip(request.getShippingAddress().getZip());
|
||||
order.setShippingCity(request.getShippingAddress().getCity());
|
||||
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
|
||||
} else {
|
||||
order.setShippingFirstName(order.getBillingFirstName());
|
||||
order.setShippingLastName(order.getBillingLastName());
|
||||
order.setShippingCompanyName(order.getBillingCompanyName());
|
||||
order.setShippingContactPerson(order.getBillingContactPerson());
|
||||
order.setShippingAddressLine1(order.getBillingAddressLine1());
|
||||
order.setShippingAddressLine2(order.getBillingAddressLine2());
|
||||
order.setShippingZip(order.getBillingZip());
|
||||
order.setShippingCity(order.getBillingCity());
|
||||
order.setShippingCountryCode(order.getBillingCountryCode());
|
||||
}
|
||||
|
||||
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
|
||||
|
||||
BigDecimal subtotal = BigDecimal.ZERO;
|
||||
order.setSubtotalChf(BigDecimal.ZERO);
|
||||
order.setTotalChf(BigDecimal.ZERO);
|
||||
order.setDiscountChf(BigDecimal.ZERO);
|
||||
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
|
||||
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||
|
||||
order = orderRepo.save(order);
|
||||
|
||||
List<OrderItem> savedItems = new ArrayList<>();
|
||||
|
||||
for (QuoteLineItem qItem : quoteItems) {
|
||||
OrderItem oItem = new OrderItem();
|
||||
oItem.setOrder(order);
|
||||
oItem.setOriginalFilename(qItem.getOriginalFilename());
|
||||
oItem.setQuantity(qItem.getQuantity());
|
||||
oItem.setColorCode(qItem.getColorCode());
|
||||
oItem.setMaterialCode(session.getMaterialCode());
|
||||
|
||||
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
||||
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
||||
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
||||
|
||||
UUID fileUuid = UUID.randomUUID();
|
||||
String ext = getExtension(qItem.getOriginalFilename());
|
||||
String storedFilename = fileUuid.toString() + "." + ext;
|
||||
|
||||
oItem.setStoredFilename(storedFilename);
|
||||
oItem.setStoredRelativePath("PENDING");
|
||||
oItem.setMimeType("application/octet-stream");
|
||||
oItem.setCreatedAt(OffsetDateTime.now());
|
||||
|
||||
oItem = orderItemRepo.save(oItem);
|
||||
|
||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||
oItem.setStoredRelativePath(relativePath);
|
||||
|
||||
if (qItem.getStoredPath() != null) {
|
||||
try {
|
||||
Path sourcePath = Paths.get(qItem.getStoredPath());
|
||||
if (Files.exists(sourcePath)) {
|
||||
storageService.store(sourcePath, Paths.get(relativePath));
|
||||
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
oItem = orderItemRepo.save(oItem);
|
||||
savedItems.add(oItem);
|
||||
subtotal = subtotal.add(oItem.getLineTotalChf());
|
||||
}
|
||||
|
||||
order.setSubtotalChf(subtotal);
|
||||
if (order.getShippingCostChf() == null) {
|
||||
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||
}
|
||||
|
||||
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||
order.setTotalChf(total);
|
||||
|
||||
session.setConvertedOrderId(order.getId());
|
||||
session.setStatus("CONVERTED");
|
||||
quoteSessionRepo.save(session);
|
||||
|
||||
// Generate Invoice and QR Bill
|
||||
generateAndSaveDocuments(order, savedItems);
|
||||
|
||||
return orderRepo.save(order);
|
||||
}
|
||||
|
||||
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
|
||||
try {
|
||||
// 1. Generate QR Bill
|
||||
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
|
||||
String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
|
||||
if (qrBillSvg.contains("<?xml")) {
|
||||
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
||||
if (svgStartIndex != -1) {
|
||||
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Save QR Bill SVG
|
||||
String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg";
|
||||
saveFileBytes(qrBillSvgBytes, qrRelativePath);
|
||||
|
||||
// 2. Prepare Invoice Variables
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("sellerDisplayName", "3D Fab Switzerland");
|
||||
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
|
||||
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||
vars.put("sellerEmail", "info@3dfab.ch");
|
||||
|
||||
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
||||
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
|
||||
String buyerName = "BUSINESS".equals(order.getBillingCustomerType())
|
||||
? order.getBillingCompanyName()
|
||||
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||
vars.put("buyerDisplayName", buyerName);
|
||||
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
||||
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
||||
|
||||
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
||||
Map<String, Object> line = new HashMap<>();
|
||||
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
||||
line.put("quantity", i.getQuantity());
|
||||
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
||||
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
||||
return line;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> setupLine = new HashMap<>();
|
||||
setupLine.put("description", "Costo Setup");
|
||||
setupLine.put("quantity", 1);
|
||||
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||
invoiceLineItems.add(setupLine);
|
||||
|
||||
Map<String, Object> shippingLine = new HashMap<>();
|
||||
shippingLine.put("description", "Spedizione");
|
||||
shippingLine.put("quantity", 1);
|
||||
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||
invoiceLineItems.add(shippingLine);
|
||||
|
||||
vars.put("invoiceLineItems", invoiceLineItems);
|
||||
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
||||
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
||||
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
|
||||
|
||||
// 3. Generate PDF
|
||||
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||
|
||||
// Save PDF
|
||||
String pdfRelativePath = "orders/" + order.getId() + "/documents/invoice-" + order.getId() + ".pdf";
|
||||
saveFileBytes(pdfBytes, pdfRelativePath);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
// Don't fail the order if document generation fails, but log it
|
||||
// TODO: Better error handling
|
||||
}
|
||||
}
|
||||
|
||||
private void saveFileBytes(byte[] content, String relativePath) {
|
||||
// Since StorageService takes paths, we might need to write to temp first or check if it supports bytes/streams
|
||||
// Simulating via temp file for now as StorageService.store takes a Path
|
||||
try {
|
||||
Path tempFile = Files.createTempFile("print-calc-upload", ".tmp");
|
||||
Files.write(tempFile, content);
|
||||
storageService.store(tempFile, Paths.get(relativePath));
|
||||
Files.delete(tempFile);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to save file " + relativePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "stl";
|
||||
int i = filename.lastIndexOf('.');
|
||||
if (i > 0) {
|
||||
return filename.substring(i + 1);
|
||||
}
|
||||
return "stl";
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class ProfileManager {
|
||||
@@ -59,6 +60,18 @@ public class ProfileManager {
|
||||
return resolveInheritance(profilePath);
|
||||
}
|
||||
|
||||
public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
|
||||
String resolvedName = profileAliases.getOrDefault(machineName, machineName);
|
||||
if (nozzleDiameter == null) return resolvedName;
|
||||
|
||||
String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
|
||||
String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
|
||||
String candidate = base + " " + formatted + " nozzle";
|
||||
|
||||
Path exists = findProfileFile(candidate, "machine");
|
||||
return exists != null ? candidate : resolvedName;
|
||||
}
|
||||
|
||||
private Path findProfileFile(String name, String type) {
|
||||
// Check aliases first
|
||||
String resolvedName = profileAliases.getOrDefault(name, name);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.printcalculator.entity.Order;
|
||||
import net.codecrete.qrbill.generator.Bill;
|
||||
import net.codecrete.qrbill.generator.GraphicsFormat;
|
||||
import net.codecrete.qrbill.generator.QRBill;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class QrBillService {
|
||||
|
||||
public byte[] generateQrBillSvg(Order order) {
|
||||
Bill bill = createBillFromOrder(order);
|
||||
return QRBill.generate(bill);
|
||||
}
|
||||
|
||||
public Bill createBillFromOrder(Order order) {
|
||||
Bill bill = new Bill();
|
||||
|
||||
// Creditor (Merchant)
|
||||
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
|
||||
bill.setCreditor(createAddress(
|
||||
"Küng, Joe",
|
||||
"Via G. Pioda 29a",
|
||||
"6710",
|
||||
"Biasca",
|
||||
"CH"
|
||||
));
|
||||
|
||||
// Debtor (Customer)
|
||||
String debtorName;
|
||||
if ("BUSINESS".equals(order.getBillingCustomerType())) {
|
||||
debtorName = order.getBillingCompanyName();
|
||||
} else {
|
||||
debtorName = order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||
}
|
||||
|
||||
bill.setDebtor(createAddress(
|
||||
debtorName,
|
||||
order.getBillingAddressLine1(), // Assuming simple address for now. Splitting might be needed if street/house number are separate
|
||||
order.getBillingZip(),
|
||||
order.getBillingCity(),
|
||||
order.getBillingCountryCode()
|
||||
));
|
||||
|
||||
// Amount
|
||||
bill.setAmount(order.getTotalChf());
|
||||
bill.setCurrency("CHF");
|
||||
|
||||
// Reference
|
||||
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
|
||||
bill.setUnstructuredMessage("Order " + order.getId());
|
||||
|
||||
return bill;
|
||||
}
|
||||
|
||||
private net.codecrete.qrbill.generator.Address createAddress(String name, String street, String zip, String city, String country) {
|
||||
net.codecrete.qrbill.generator.Address address = new net.codecrete.qrbill.generator.Address();
|
||||
address.setName(name);
|
||||
address.setStreet(street);
|
||||
address.setPostalCode(zip);
|
||||
address.setTown(city);
|
||||
address.setCountryCode(country);
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,21 @@ public class QuoteCalculator {
|
||||
// --- CALCULATIONS ---
|
||||
|
||||
// Material Cost: (weight / 1000) * costPerKg
|
||||
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
// DISCOUNTED Support material to avoid penalizing users for default supports
|
||||
BigDecimal weightToCharge;
|
||||
if (stats.getModelWeightGrams() != null && stats.getSupportWeightGrams() != null) {
|
||||
// Charge 100% for model + 20% for support
|
||||
weightToCharge = BigDecimal.valueOf(stats.getModelWeightGrams())
|
||||
.add(BigDecimal.valueOf(stats.getSupportWeightGrams()).multiply(BigDecimal.valueOf(0.2)));
|
||||
} else {
|
||||
weightToCharge = BigDecimal.valueOf(stats.getFilamentWeightGrams());
|
||||
}
|
||||
|
||||
BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
|
||||
|
||||
// Machine Cost: Tiered
|
||||
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
||||
|
||||
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||
|
||||
@@ -13,8 +13,10 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class SlicerService {
|
||||
@@ -39,21 +41,15 @@ public class SlicerService {
|
||||
|
||||
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
|
||||
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
|
||||
// 1. Prepare Profiles
|
||||
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
||||
|
||||
// Apply Overrides
|
||||
if (machineOverrides != null) {
|
||||
machineOverrides.forEach(machineProfile::put);
|
||||
}
|
||||
if (processOverrides != null) {
|
||||
processOverrides.forEach(processProfile::put);
|
||||
}
|
||||
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
|
||||
if (processOverrides != null) processOverrides.forEach(processProfile::put);
|
||||
|
||||
// 2. Create Temp Dir
|
||||
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||
|
||||
try {
|
||||
File mFile = tempDir.resolve("machine.json").toFile();
|
||||
File fFile = tempDir.resolve("filament.json").toFile();
|
||||
@@ -63,84 +59,61 @@ public class SlicerService {
|
||||
mapper.writeValue(fFile, filamentProfile);
|
||||
mapper.writeValue(pFile, processProfile);
|
||||
|
||||
// 3. Build Command
|
||||
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
|
||||
// Load machine settings
|
||||
command.add("--load-settings");
|
||||
command.add(mFile.getAbsolutePath());
|
||||
|
||||
// Load process settings
|
||||
command.add("--load-settings");
|
||||
command.add(pFile.getAbsolutePath());
|
||||
command.add("--load-filaments");
|
||||
command.add(fFile.getAbsolutePath());
|
||||
|
||||
command.add("--ensure-on-bed");
|
||||
command.add("--arrange");
|
||||
command.add("1"); // force arrange
|
||||
command.add("--slice");
|
||||
command.add("0"); // slice plate 0
|
||||
command.add("1");
|
||||
command.add("--outputdir");
|
||||
command.add(tempDir.toAbsolutePath().toString());
|
||||
// Need to handle Mac structure for console if needed?
|
||||
// Usually the binary at Contents/MacOS/OrcaSlicer works fine as console app.
|
||||
|
||||
command.add("--slice");
|
||||
command.add("0");
|
||||
|
||||
command.add(inputStl.getAbsolutePath());
|
||||
|
||||
logger.info("Executing Slicer: " + String.join(" ", command));
|
||||
|
||||
// 4. Run Process
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(tempDir.toFile());
|
||||
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
|
||||
runSlicerCommand(command, tempDir);
|
||||
|
||||
Process process = pb.start();
|
||||
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
|
||||
|
||||
if (!finished) {
|
||||
process.destroy();
|
||||
throw new IOException("Slicer timed out");
|
||||
try (Stream<Path> s = Files.list(tempDir)) {
|
||||
Optional<Path> found = s.filter(p -> p.toString().endsWith(".gcode")).findFirst();
|
||||
if (found.isPresent()) return gCodeParser.parse(found.get().toFile());
|
||||
else throw new IOException("No GCode found in " + tempDir);
|
||||
}
|
||||
|
||||
if (process.exitValue() != 0) {
|
||||
// Read stderr
|
||||
String error = new String(process.getErrorStream().readAllBytes());
|
||||
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
|
||||
}
|
||||
|
||||
// 5. Find Output GCode
|
||||
// Usually [basename].gcode or plate_1.gcode
|
||||
String basename = inputStl.getName();
|
||||
if (basename.toLowerCase().endsWith(".stl")) {
|
||||
basename = basename.substring(0, basename.length() - 4);
|
||||
}
|
||||
|
||||
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
|
||||
if (!gcodeFile.exists()) {
|
||||
// Try plate_1.gcode fallback
|
||||
File alt = tempDir.resolve("plate_1.gcode").toFile();
|
||||
if (alt.exists()) {
|
||||
gcodeFile = alt;
|
||||
} else {
|
||||
throw new IOException("GCode output not found in " + tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Parse Results
|
||||
return gCodeParser.parse(gcodeFile);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted during slicing", e);
|
||||
} finally {
|
||||
// Cleanup temp dir
|
||||
// In production we should delete, for debugging we might want to keep?
|
||||
// Let's delete for now on success.
|
||||
// recursiveDelete(tempDir);
|
||||
// Leaving it effectively "leaks" temp, but safer for persistent debugging?
|
||||
// Implementation detail: Use a utility to clean up.
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(tempDir.toFile());
|
||||
|
||||
Map<String, String> env = pb.environment();
|
||||
env.put("HOME", "/tmp");
|
||||
env.put("QT_QPA_PLATFORM", "offscreen");
|
||||
|
||||
Process process = pb.start();
|
||||
if (!process.waitFor(5, TimeUnit.MINUTES)) {
|
||||
process.destroy();
|
||||
throw new IOException("Slicer timeout");
|
||||
}
|
||||
|
||||
if (process.exitValue() != 0) {
|
||||
String out = new String(process.getInputStream().readAllBytes());
|
||||
String err = new String(process.getErrorStream().readAllBytes());
|
||||
throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.printcalculator.model.StlBounds;
|
||||
import com.printcalculator.model.StlShiftResult;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class StlService {
|
||||
|
||||
public StlBounds readBounds(File stlFile) throws IOException {
|
||||
long size = stlFile.length();
|
||||
if (size >= 84 && isBinaryStl(stlFile, size)) {
|
||||
return readBinaryBounds(stlFile);
|
||||
}
|
||||
return readAsciiBounds(stlFile);
|
||||
}
|
||||
|
||||
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
|
||||
int bedX, int bedY, int bedZ) throws IOException {
|
||||
double sizeX = bounds.sizeX();
|
||||
double sizeY = bounds.sizeY();
|
||||
double sizeZ = bounds.sizeZ();
|
||||
|
||||
double targetMinX = (bedX - sizeX) / 2.0;
|
||||
double targetMinY = (bedY - sizeY) / 2.0;
|
||||
double targetMinZ = 0.0;
|
||||
|
||||
double offsetX = targetMinX - bounds.minX();
|
||||
double offsetY = targetMinY - bounds.minY();
|
||||
double offsetZ = targetMinZ - bounds.minZ();
|
||||
|
||||
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
|
||||
if (!needsShift) {
|
||||
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
|
||||
}
|
||||
|
||||
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
|
||||
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
|
||||
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
|
||||
}
|
||||
|
||||
private boolean isBinaryStl(File stlFile, long size) throws IOException {
|
||||
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
||||
raf.seek(80);
|
||||
long triangleCount = readLEUInt32(raf);
|
||||
long expected = 84L + triangleCount * 50L;
|
||||
return expected == size;
|
||||
}
|
||||
}
|
||||
|
||||
private StlBounds readBinaryBounds(File stlFile) throws IOException {
|
||||
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
||||
raf.seek(80);
|
||||
long triangleCount = readLEUInt32(raf);
|
||||
raf.seek(84);
|
||||
|
||||
BoundsAccumulator acc = new BoundsAccumulator();
|
||||
for (long i = 0; i < triangleCount; i++) {
|
||||
// skip normal
|
||||
readLEFloat(raf);
|
||||
readLEFloat(raf);
|
||||
readLEFloat(raf);
|
||||
// 3 vertices
|
||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
||||
// skip attribute byte count
|
||||
raf.skipBytes(2);
|
||||
}
|
||||
return acc.toBounds();
|
||||
}
|
||||
}
|
||||
|
||||
private StlBounds readAsciiBounds(File stlFile) throws IOException {
|
||||
BoundsAccumulator acc = new BoundsAccumulator();
|
||||
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (!line.startsWith("vertex")) continue;
|
||||
String[] parts = line.split("\\s+");
|
||||
if (parts.length < 4) continue;
|
||||
double x = Double.parseDouble(parts[1]);
|
||||
double y = Double.parseDouble(parts[2]);
|
||||
double z = Double.parseDouble(parts[3]);
|
||||
acc.accept(x, y, z);
|
||||
}
|
||||
}
|
||||
return acc.toBounds();
|
||||
}
|
||||
|
||||
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
||||
long size = input.length();
|
||||
if (size >= 84 && isBinaryStl(input, size)) {
|
||||
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
|
||||
} else {
|
||||
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
||||
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
|
||||
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.startsWith("vertex")) {
|
||||
writer.write(line);
|
||||
writer.newLine();
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+");
|
||||
if (parts.length < 4) {
|
||||
writer.write(line);
|
||||
writer.newLine();
|
||||
continue;
|
||||
}
|
||||
double x = Double.parseDouble(parts[1]) + offsetX;
|
||||
double y = Double.parseDouble(parts[2]) + offsetY;
|
||||
double z = Double.parseDouble(parts[3]) + offsetZ;
|
||||
int idx = line.indexOf("vertex");
|
||||
String indent = idx > 0 ? line.substring(0, idx) : "";
|
||||
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
|
||||
writer.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
||||
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
|
||||
OutputStream out = new FileOutputStream(output)) {
|
||||
byte[] header = new byte[80];
|
||||
raf.readFully(header);
|
||||
out.write(header);
|
||||
|
||||
long triangleCount = readLEUInt32(raf);
|
||||
writeLEUInt32(out, triangleCount);
|
||||
|
||||
for (long i = 0; i < triangleCount; i++) {
|
||||
// normal
|
||||
writeLEFloat(out, readLEFloat(raf));
|
||||
writeLEFloat(out, readLEFloat(raf));
|
||||
writeLEFloat(out, readLEFloat(raf));
|
||||
|
||||
// vertices
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
||||
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
||||
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
||||
|
||||
// attribute byte count
|
||||
int b1 = raf.read();
|
||||
int b2 = raf.read();
|
||||
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
|
||||
out.write(b1);
|
||||
out.write(b2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long readLEUInt32(RandomAccessFile raf) throws IOException {
|
||||
int b1 = raf.read();
|
||||
int b2 = raf.read();
|
||||
int b3 = raf.read();
|
||||
int b4 = raf.read();
|
||||
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
||||
return ((long) b1 & 0xFF)
|
||||
| (((long) b2 & 0xFF) << 8)
|
||||
| (((long) b3 & 0xFF) << 16)
|
||||
| (((long) b4 & 0xFF) << 24);
|
||||
}
|
||||
|
||||
private int readLEInt(RandomAccessFile raf) throws IOException {
|
||||
int b1 = raf.read();
|
||||
int b2 = raf.read();
|
||||
int b3 = raf.read();
|
||||
int b4 = raf.read();
|
||||
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
||||
return (b1 & 0xFF)
|
||||
| ((b2 & 0xFF) << 8)
|
||||
| ((b3 & 0xFF) << 16)
|
||||
| ((b4 & 0xFF) << 24);
|
||||
}
|
||||
|
||||
private float readLEFloat(RandomAccessFile raf) throws IOException {
|
||||
return Float.intBitsToFloat(readLEInt(raf));
|
||||
}
|
||||
|
||||
private void writeLEUInt32(OutputStream out, long value) throws IOException {
|
||||
out.write((int) (value & 0xFF));
|
||||
out.write((int) ((value >> 8) & 0xFF));
|
||||
out.write((int) ((value >> 16) & 0xFF));
|
||||
out.write((int) ((value >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
private void writeLEFloat(OutputStream out, float value) throws IOException {
|
||||
int bits = Float.floatToIntBits(value);
|
||||
out.write(bits & 0xFF);
|
||||
out.write((bits >> 8) & 0xFF);
|
||||
out.write((bits >> 16) & 0xFF);
|
||||
out.write((bits >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
private static class BoundsAccumulator {
|
||||
private boolean hasPoint = false;
|
||||
private double minX;
|
||||
private double minY;
|
||||
private double minZ;
|
||||
private double maxX;
|
||||
private double maxY;
|
||||
private double maxZ;
|
||||
|
||||
void accept(double x, double y, double z) {
|
||||
if (!hasPoint) {
|
||||
minX = maxX = x;
|
||||
minY = maxY = y;
|
||||
minZ = maxZ = z;
|
||||
hasPoint = true;
|
||||
return;
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (z < minZ) minZ = z;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
if (z > maxZ) maxZ = z;
|
||||
}
|
||||
|
||||
StlBounds toBounds() throws IOException {
|
||||
if (!hasPoint) {
|
||||
throw new IOException("STL appears to contain no vertices");
|
||||
}
|
||||
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.Path;
|
||||
import java.io.IOException;
|
||||
|
||||
public interface StorageService {
|
||||
void init();
|
||||
void store(MultipartFile file, Path destination) throws IOException;
|
||||
void store(Path source, Path destination) throws IOException;
|
||||
void delete(Path path) throws IOException;
|
||||
Resource loadAsResource(Path path) throws IOException;
|
||||
}
|
||||
@@ -18,3 +18,9 @@ profiles.root=${PROFILES_DIR:profiles}
|
||||
# File Upload Limits
|
||||
spring.servlet.multipart.max-file-size=200MB
|
||||
spring.servlet.multipart.max-request-size=200MB
|
||||
|
||||
# ClamAV Configuration
|
||||
clamav.host=${CLAMAV_HOST:clamav}
|
||||
clamav.port=${CLAMAV_PORT:3310}
|
||||
clamav.enabled=${CLAMAV_ENABLED:false}
|
||||
|
||||
|
||||
84
backend/src/main/resources/templates/invoice.html
Normal file
84
backend/src/main/resources/templates/invoice.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 15mm; }
|
||||
body { font-family: sans-serif; font-size: 10.5pt; }
|
||||
.header { display: flex; justify-content: space-between; }
|
||||
.addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
|
||||
th, td { padding: 6px; border-bottom: 1px solid #ccc; }
|
||||
th { text-align: left; }
|
||||
.totals { margin-top: 6mm; width: 40%; margin-left: auto; }
|
||||
.totals td { border: none; }
|
||||
.page-break { page-break-before: always; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
|
||||
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
|
||||
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
|
||||
<div th:text="${sellerEmail}">email@example.com</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div><strong>Fattura</strong></div>
|
||||
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
|
||||
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
|
||||
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addresses">
|
||||
<div>
|
||||
<div><strong>Fatturare a</strong></div>
|
||||
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Descrizione</th>
|
||||
<th style="text-align:right;">Qtà</th>
|
||||
<th style="text-align:right;">Prezzo</th>
|
||||
<th style="text-align:right;">Totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="lineItem : ${invoiceLineItems}">
|
||||
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
|
||||
<td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
|
||||
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
|
||||
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tr>
|
||||
<td>Subtotale</td>
|
||||
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Totale</strong></td>
|
||||
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
|
||||
Pagamento entro 7 giorni. Grazie.
|
||||
</div>
|
||||
|
||||
<div style="page-break-before: always;"></div>
|
||||
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.printcalculator;
|
||||
|
||||
import com.printcalculator.controller.QuoteSessionController;
|
||||
import com.printcalculator.dto.PrintSettingsDto;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.StorageService;
|
||||
import com.printcalculator.service.StlService;
|
||||
import com.printcalculator.service.ProfileManager;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.model.StlBounds;
|
||||
import com.printcalculator.model.StlShiftResult;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
|
||||
@WebMvcTest(QuoteSessionController.class)
|
||||
public class ManualSessionPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private QuoteSessionController controller;
|
||||
|
||||
@MockitoBean
|
||||
private QuoteSessionRepository sessionRepo;
|
||||
|
||||
@MockitoBean
|
||||
private QuoteLineItemRepository lineItemRepo; // Mock this too
|
||||
|
||||
@MockitoBean
|
||||
private SlicerService slicerService;
|
||||
|
||||
@MockitoBean
|
||||
private StorageService storageService;
|
||||
|
||||
@MockitoBean
|
||||
private StlService stlService;
|
||||
|
||||
@MockitoBean
|
||||
private ProfileManager profileManager;
|
||||
|
||||
@MockitoBean
|
||||
private QuoteCalculator quoteCalculator;
|
||||
|
||||
@MockitoBean
|
||||
private PrinterMachineRepository machineRepo;
|
||||
|
||||
@MockitoBean
|
||||
private com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Add this if needed by controller
|
||||
|
||||
@Test
|
||||
public void testSettingsPersistence() throws Exception {
|
||||
// Prepare
|
||||
UUID sessionId = UUID.randomUUID();
|
||||
QuoteSession session = new QuoteSession();
|
||||
session.setId(sessionId);
|
||||
session.setMaterialCode("pla_basic"); // Initial state
|
||||
|
||||
when(sessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
||||
when(sessionRepo.save(any(QuoteSession.class))).thenAnswer(i -> i.getArguments()[0]);
|
||||
when(lineItemRepo.save(any(QuoteLineItem.class))).thenAnswer(i -> i.getArguments()[0]);
|
||||
|
||||
// 2. Add Item with Custom Settings
|
||||
PrintSettingsDto settings = new PrintSettingsDto();
|
||||
settings.setComplexityMode("ADVANCED");
|
||||
settings.setMaterial("petg_basic");
|
||||
settings.setLayerHeight(0.12);
|
||||
settings.setInfillDensity(50.0);
|
||||
settings.setInfillPattern("gyroid");
|
||||
settings.setSupportsEnabled(true);
|
||||
settings.setNozzleDiameter(0.6);
|
||||
settings.setNotes("Test Notes");
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "test.stl", "application/octet-stream", "dummy content".getBytes());
|
||||
|
||||
// Mock dependencies
|
||||
when(machineRepo.findFirstByIsActiveTrue()).thenReturn(Optional.of(new PrinterMachine(){{
|
||||
setPrinterDisplayName("TestPrinter");
|
||||
setSlicerMachineProfile("TestProfile");
|
||||
setBuildVolumeXMm(256);
|
||||
setBuildVolumeYMm(256);
|
||||
setBuildVolumeZMm(256);
|
||||
}}));
|
||||
when(slicerService.slice(any(), any(), any(), any(), any(), any())).thenReturn(new PrintStats(100, "1m", 10.0, 100));
|
||||
when(quoteCalculator.calculate(any(), any(), any())).thenReturn(
|
||||
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
|
||||
);
|
||||
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
|
||||
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
|
||||
when(profileManager.resolveMachineProfileName(any(), any())).thenAnswer(i -> i.getArguments()[0]);
|
||||
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
|
||||
@Override
|
||||
public File getFile() { return new File("dummy"); }
|
||||
});
|
||||
|
||||
controller.addItemToExistingSession(sessionId, settings, file);
|
||||
|
||||
// 3. Verify Session Updated via Save Call capture
|
||||
ArgumentCaptor<QuoteSession> captor = ArgumentCaptor.forClass(QuoteSession.class);
|
||||
verify(sessionRepo).save(captor.capture());
|
||||
|
||||
QuoteSession updatedSession = captor.getValue();
|
||||
|
||||
assertEquals("petg_basic", updatedSession.getMaterialCode());
|
||||
assertEquals(0, BigDecimal.valueOf(0.12).compareTo(updatedSession.getLayerHeightMm()));
|
||||
assertEquals(50, updatedSession.getInfillPercent());
|
||||
assertEquals("gyroid", updatedSession.getInfillPattern());
|
||||
assertTrue(updatedSession.getSupportsEnabled());
|
||||
assertEquals(0, BigDecimal.valueOf(0.6).compareTo(updatedSession.getNozzleDiameterMm()));
|
||||
assertEquals("Test Notes", updatedSession.getNotes());
|
||||
|
||||
System.out.println("Verification Passed: Settings were persisted to Session.");
|
||||
}
|
||||
@org.springframework.boot.test.context.TestConfiguration
|
||||
static class TestConfig {
|
||||
@org.springframework.context.annotation.Bean
|
||||
public org.springframework.transaction.PlatformTransactionManager transactionManager() {
|
||||
return org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import com.printcalculator.service.ClamAVService;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@TestConfiguration
|
||||
public class TestConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public ClamAVService mockClamAVService() {
|
||||
return new ClamAVService("localhost", 3310, true) {
|
||||
@Override
|
||||
public boolean scan(InputStream inputStream) {
|
||||
return true; // Always clean for tests
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.printcalculator.dto.CreateOrderRequest;
|
||||
import com.printcalculator.dto.CustomerDto;
|
||||
import com.printcalculator.dto.AddressDto;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import com.printcalculator.service.ClamAVService;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@org.springframework.test.context.TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL",
|
||||
"spring.datasource.driverClassName=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
||||
})
|
||||
class OrderIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
private ClamAVService clamAVService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private QuoteSessionRepository sessionRepository;
|
||||
|
||||
@Autowired
|
||||
private QuoteLineItemRepository lineItemRepository;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private UUID sessionId;
|
||||
private UUID lineItemId;
|
||||
private final String TEST_FILENAME = "test_model.stl";
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
// Mock ClamAV to always return true (safe)
|
||||
when(clamAVService.scan(any())).thenReturn(true);
|
||||
|
||||
// 1. Create Quote Session
|
||||
QuoteSession session = new QuoteSession();
|
||||
session.setStatus("ACTIVE");
|
||||
session.setMaterialCode("PLA");
|
||||
session.setPricingVersion("v1");
|
||||
session.setCreatedAt(OffsetDateTime.now());
|
||||
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
|
||||
session.setSetupCostChf(BigDecimal.valueOf(5.00));
|
||||
session.setSupportsEnabled(false);
|
||||
session = sessionRepository.save(session);
|
||||
this.sessionId = session.getId();
|
||||
|
||||
// 2. Create Dummy File on Disk (storage_quotes)
|
||||
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
|
||||
Files.createDirectories(sessionDir);
|
||||
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
|
||||
Files.writeString(filePath, "dummy content");
|
||||
|
||||
// 3. Create Quote Line Item
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setStatus("READY");
|
||||
item.setOriginalFilename(TEST_FILENAME);
|
||||
item.setStoredPath(filePath.toString());
|
||||
item.setQuantity(2);
|
||||
item.setPrintTimeSeconds(120);
|
||||
item.setMaterialGrams(BigDecimal.valueOf(10.5));
|
||||
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
item = lineItemRepository.save(item);
|
||||
this.lineItemId = item.getId();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() throws Exception {
|
||||
// Cleanup generated files
|
||||
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
|
||||
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
|
||||
|
||||
// Clean DB
|
||||
orderRepository.deleteAll();
|
||||
lineItemRepository.deleteAll();
|
||||
sessionRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
|
||||
// Prepare Request
|
||||
CreateOrderRequest request = new CreateOrderRequest();
|
||||
|
||||
CustomerDto customer = new CustomerDto();
|
||||
customer.setEmail("integration@test.com");
|
||||
customer.setCustomerType("PRIVATE");
|
||||
request.setCustomer(customer);
|
||||
|
||||
AddressDto billing = new AddressDto();
|
||||
billing.setFirstName("John");
|
||||
billing.setLastName("Doe");
|
||||
billing.setAddressLine1("Street 1");
|
||||
billing.setCity("City");
|
||||
billing.setZip("1000");
|
||||
billing.setCountryCode("CH");
|
||||
request.setBillingAddress(billing);
|
||||
|
||||
request.setShippingSameAsBilling(true);
|
||||
|
||||
// Execute Request
|
||||
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Verify Session Status
|
||||
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
|
||||
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
|
||||
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
|
||||
|
||||
UUID orderId = updatedSession.getConvertedOrderId();
|
||||
|
||||
// Verify File Copy
|
||||
Path orderStorageDir = Paths.get("storage_orders");
|
||||
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
|
||||
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
|
||||
|
||||
try (var stream = Files.walk(orderStorageDir)) {
|
||||
boolean fileFound = stream
|
||||
.filter(Files::isRegularFile)
|
||||
.anyMatch(path -> {
|
||||
try {
|
||||
return Files.readString(path).equals("dummy content");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,10 @@ class GCodeParserTest {
|
||||
PrintStats stats = parser.parse(tempFile);
|
||||
|
||||
// Assert
|
||||
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3
|
||||
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||
assertEquals(10.5, stats.filamentWeightGrams(), 0.001);
|
||||
assertEquals(3000.0, stats.filamentLengthMm(), 0.001);
|
||||
assertEquals(3723L, stats.getPrintTimeSeconds()); // 3600 + 120 + 3
|
||||
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
|
||||
assertEquals(10.5, stats.getFilamentWeightGrams(), 0.001);
|
||||
assertEquals(3000.0, stats.getFilamentLengthMm(), 0.001);
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
@@ -49,8 +49,8 @@ class GCodeParserTest {
|
||||
GCodeParser parser = new GCodeParser();
|
||||
PrintStats stats = parser.parse(tempFile);
|
||||
|
||||
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
||||
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
|
||||
assertEquals(750L, stats.getPrintTimeSeconds()); // 12*60 + 30
|
||||
assertEquals(5.0, stats.getFilamentWeightGrams(), 0.001);
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
@@ -69,8 +69,8 @@ class GCodeParserTest {
|
||||
GCodeParser parser = new GCodeParser();
|
||||
PrintStats stats = parser.parse(tempFile);
|
||||
|
||||
assertEquals(3723L, stats.printTimeSeconds());
|
||||
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||
assertEquals(3723L, stats.getPrintTimeSeconds());
|
||||
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
@@ -87,8 +87,8 @@ class GCodeParserTest {
|
||||
GCodeParser parser = new GCodeParser();
|
||||
PrintStats stats = parser.parse(tempFile);
|
||||
|
||||
assertEquals(3723L, stats.printTimeSeconds());
|
||||
assertEquals("01:02:03", stats.printTimeFormatted());
|
||||
assertEquals(3723L, stats.getPrintTimeSeconds());
|
||||
assertEquals("01:02:03", stats.getPrintTimeFormatted());
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
@@ -105,8 +105,8 @@ class GCodeParserTest {
|
||||
GCodeParser parser = new GCodeParser();
|
||||
PrintStats stats = parser.parse(tempFile);
|
||||
|
||||
assertEquals(321L, stats.printTimeSeconds());
|
||||
assertEquals("5m 21s", stats.printTimeFormatted());
|
||||
assertEquals(321L, stats.getPrintTimeSeconds());
|
||||
assertEquals("5m 21s", stats.getPrintTimeFormatted());
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
||||
class SlicerServiceTest {
|
||||
|
||||
@Mock
|
||||
private ProfileManager profileManager;
|
||||
|
||||
@Mock
|
||||
private GCodeParser gCodeParser;
|
||||
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private SlicerService slicerService;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// Captured execution details
|
||||
private List<String> lastCommand;
|
||||
private Path lastTempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
|
||||
// Subclass to override runSlicerCommand
|
||||
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
|
||||
@Override
|
||||
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
|
||||
lastCommand = command;
|
||||
lastTempDir = tempDir;
|
||||
// Don't run actual process.
|
||||
// Simulate GCode output creation for the parser to find?
|
||||
// Or just let it fail at parser step since we only care about JSON generation here?
|
||||
// For a full test, we should create a dummy GCode file.
|
||||
|
||||
File stl = new File(command.get(command.size() - 1));
|
||||
String basename = stl.getName().replace(".stl", "");
|
||||
Files.createFile(tempDir.resolve(basename + ".gcode"));
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Profile Responses
|
||||
ObjectNode emptyNode = mapper.createObjectNode();
|
||||
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
|
||||
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
|
||||
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
|
||||
|
||||
// Mock Parser
|
||||
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
|
||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
||||
Files.createFile(dummyStl.toPath());
|
||||
|
||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
|
||||
|
||||
assertNotNull(lastTempDir);
|
||||
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
|
||||
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
|
||||
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
|
||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
||||
Files.createFile(dummyStl.toPath());
|
||||
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
processOverrides.put("layer_height", "0.12");
|
||||
|
||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
|
||||
|
||||
File processJsonFile = lastTempDir.resolve("process.json").toFile();
|
||||
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
|
||||
|
||||
assertTrue(processJson.has("layer_height"));
|
||||
assertEquals("0.12", processJson.get("layer_height").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
|
||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
||||
Files.createFile(dummyStl.toPath());
|
||||
|
||||
Map<String, String> processOverrides = new HashMap<>();
|
||||
processOverrides.put("sparse_infill_density", "25%");
|
||||
processOverrides.put("enable_support", "1");
|
||||
|
||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
|
||||
|
||||
File processJsonFile = lastTempDir.resolve("process.json").toFile();
|
||||
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
|
||||
|
||||
assertEquals("25%", processJson.get("sparse_infill_density").asText());
|
||||
assertEquals("1", processJson.get("enable_support").asText());
|
||||
}
|
||||
}
|
||||
1
db.sql
1
db.sql
@@ -12,6 +12,7 @@ create table printer_machine
|
||||
fleet_weight numeric(6, 3) not null default 1.000,
|
||||
|
||||
is_active boolean not null default true,
|
||||
slicer_machine_profile varchar(255),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ services:
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- TEMP_DIR=/app/temp
|
||||
- PROFILES_DIR=/app/profiles
|
||||
- CLAMAV_HOST=host.docker.internal
|
||||
- CLAMAV_PORT=3310
|
||||
- STORAGE_LOCATION=/app/storage
|
||||
restart: always
|
||||
volumes:
|
||||
- backend_profiles_${ENV}:/app/profiles
|
||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes
|
||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders
|
||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
|
||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -13,15 +13,22 @@ services:
|
||||
- DB_USERNAME=printcalc
|
||||
- DB_PASSWORD=printcalc_secret
|
||||
- SPRING_PROFILES_ACTIVE=local
|
||||
- FILAMENT_COST_PER_KG=22.0
|
||||
- MACHINE_COST_PER_HOUR=2.50
|
||||
- ENERGY_COST_PER_KWH=0.30
|
||||
- PRINTER_POWER_WATTS=150
|
||||
- MARKUP_PERCENT=20
|
||||
- TEMP_DIR=/app/temp
|
||||
- PROFILES_DIR=/app/profiles
|
||||
- CLAMAV_HOST=clamav
|
||||
- CLAMAV_PORT=3310
|
||||
- STORAGE_LOCATION=/app/storage
|
||||
depends_on:
|
||||
- db
|
||||
- clamav
|
||||
restart: unless-stopped
|
||||
|
||||
clamav:
|
||||
platform: linux/amd64
|
||||
image: clamav/clamav:latest
|
||||
container_name: print-calculator-clamav
|
||||
ports:
|
||||
- "3310:3310"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
||||
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
|
||||
@if (error() === 'VIRUS_DETECTED') {
|
||||
<app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert>
|
||||
} @else if (error()) {
|
||||
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -19,12 +21,12 @@
|
||||
<div class="mode-selector">
|
||||
<div class="mode-option"
|
||||
[class.active]="mode() === 'easy'"
|
||||
(click)="mode.set('easy')">
|
||||
(click)="setMode('easy')">
|
||||
{{ 'CALC.MODE_EASY' | translate }}
|
||||
</div>
|
||||
<div class="mode-option"
|
||||
[class.active]="mode() === 'advanced'"
|
||||
(click)="mode.set('advanced')">
|
||||
(click)="setMode('advanced')">
|
||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +37,7 @@
|
||||
[loading]="loading()"
|
||||
[uploadProgress]="uploadProgress()"
|
||||
(submitRequest)="onCalculate($event)"
|
||||
(itemRemoved)="onItemRemoved($event)"
|
||||
></app-upload-form>
|
||||
</app-card>
|
||||
</div>
|
||||
@@ -42,7 +45,8 @@
|
||||
<!-- Right Column: Result or Info -->
|
||||
<div class="col-result" #resultCol>
|
||||
|
||||
@if (loading()) {
|
||||
@if (loading() && !result()) {
|
||||
<!-- Initial Loading State (before first result) -->
|
||||
<app-card class="loading-state">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
@@ -51,6 +55,15 @@
|
||||
</div>
|
||||
</app-card>
|
||||
} @else if (result()) {
|
||||
<!-- Result State (Active or Finished) -->
|
||||
@if (loading()) {
|
||||
<!-- Small loader indicator when refining results -->
|
||||
<div class="analyzing-bar">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-quote-result
|
||||
[result]="result()!"
|
||||
(consult)="onConsult()"
|
||||
|
||||
@@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
loading = signal(false);
|
||||
uploadProgress = signal(0);
|
||||
result = signal<QuoteResult | null>(null);
|
||||
error = signal<boolean>(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
orderSuccess = signal(false);
|
||||
|
||||
@@ -48,7 +48,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
const sessionId = params['session'];
|
||||
if (sessionId) {
|
||||
if (sessionId && sessionId !== this.result()?.sessionId) {
|
||||
this.loadSession(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load session', err);
|
||||
this.error.set(true);
|
||||
this.error.set('Failed to load session');
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -106,14 +106,14 @@ export class CalculatorPageComponent implements OnInit {
|
||||
forkJoin(downloads).subscribe({
|
||||
next: (results: any[]) => {
|
||||
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
|
||||
const colors = items.map(i => i.colorCode || 'Black');
|
||||
|
||||
if (this.uploadForm) {
|
||||
this.uploadForm.setFiles(files);
|
||||
this.uploadForm.setFiles(files, colors);
|
||||
this.uploadForm.patchSettings(session);
|
||||
|
||||
// Also restore colors?
|
||||
// setFiles inits with 'Black'. We need to update them if they differ.
|
||||
// items has colorCode.
|
||||
// setFiles inits with correct colors now.
|
||||
setTimeout(() => {
|
||||
if (this.uploadForm) {
|
||||
items.forEach((item, index) => {
|
||||
@@ -122,7 +122,11 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (item.colorCode) {
|
||||
this.uploadForm.updateItemColor(index, item.colorCode);
|
||||
}
|
||||
if (item.quantity) {
|
||||
this.uploadForm.updateItemQuantityAtIndex(index, item.quantity);
|
||||
}
|
||||
});
|
||||
this.uploadForm.updateItemIdsByIndex(items.map(i => i.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -141,7 +145,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
this.currentRequest = req;
|
||||
this.loading.set(true);
|
||||
this.uploadProgress.set(0);
|
||||
this.error.set(false);
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.orderSuccess.set(false);
|
||||
|
||||
@@ -157,26 +161,45 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (typeof event === 'number') {
|
||||
this.uploadProgress.set(event);
|
||||
} else {
|
||||
// It's the result
|
||||
// It's the result (partial or final)
|
||||
const res = event as QuoteResult;
|
||||
this.result.set(res);
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
|
||||
// Show result immediately if not already showing
|
||||
if (this.step() !== 'quote') {
|
||||
this.step.set('quote');
|
||||
}
|
||||
|
||||
// Sync IDs back to upload form for future updates
|
||||
if (this.uploadForm) {
|
||||
this.uploadForm.updateItemIdsByIndex(res.items.map(i => i.id));
|
||||
}
|
||||
|
||||
// Update URL with session ID without reloading
|
||||
if (res.sessionId) {
|
||||
// Check if we need to update URL to avoid redundant navigations
|
||||
const currentSession = this.route.snapshot.queryParamMap.get('session');
|
||||
if (currentSession !== res.sessionId) {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { session: res.sessionId },
|
||||
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
||||
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.error.set(true);
|
||||
complete: () => {
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
},
|
||||
error: (err) => {
|
||||
if (typeof err === 'string') {
|
||||
this.error.set(err);
|
||||
} else {
|
||||
this.error.set('GENERIC');
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -196,10 +219,10 @@ export class CalculatorPageComponent implements OnInit {
|
||||
this.step.set('quote');
|
||||
}
|
||||
|
||||
onItemChange(event: {id?: string, fileName: string, quantity: number}) {
|
||||
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
|
||||
// 1. Update local form for consistency (UI feedback)
|
||||
if (this.uploadForm) {
|
||||
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
|
||||
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
|
||||
}
|
||||
|
||||
// 2. Update backend session if ID exists
|
||||
@@ -211,6 +234,43 @@ export class CalculatorPageComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
onItemRemoved(event: {index: number, id?: string}) {
|
||||
// 1. Update local result if exists to keep UI in sync
|
||||
const currentRes = this.result();
|
||||
if (currentRes) {
|
||||
const updatedItems = [...currentRes.items];
|
||||
updatedItems.splice(event.index, 1);
|
||||
|
||||
// Recalculate totals locally for immediate feedback
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
let itemsPrice = 0;
|
||||
|
||||
updatedItems.forEach(i => {
|
||||
totalTime += i.unitTime * i.quantity;
|
||||
totalWeight += i.unitWeight * i.quantity;
|
||||
itemsPrice += i.unitPrice * i.quantity;
|
||||
});
|
||||
|
||||
this.result.set({
|
||||
...currentRes,
|
||||
items: updatedItems,
|
||||
totalPrice: Math.round((itemsPrice + currentRes.setupCost) * 100) / 100,
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight)
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete from backend if ID exists
|
||||
if (event.id && currentRes?.sessionId) {
|
||||
this.estimator.deleteLineItem(currentRes.sessionId, event.id).subscribe({
|
||||
next: () => console.log('Line item deleted from backend'),
|
||||
error: (err) => console.error('Failed to delete line item', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSubmitOrder(orderData: any) {
|
||||
console.log('Order Submitted:', orderData);
|
||||
this.orderSuccess.set(true);
|
||||
@@ -256,4 +316,12 @@ export class CalculatorPageComponent implements OnInit {
|
||||
|
||||
this.router.navigate(['/contact']);
|
||||
}
|
||||
|
||||
setMode(mode: 'easy' | 'advanced') {
|
||||
const path = mode === 'easy' ? 'basic' : 'advanced';
|
||||
this.router.navigate(['../', path], {
|
||||
relativeTo: this.route,
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,26 @@
|
||||
|
||||
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
||||
<div class="items-list">
|
||||
@for (item of items(); track item.fileName; let i = $index) {
|
||||
<div class="item-row">
|
||||
@for (item of items(); track item; let i = $index) {
|
||||
<div class="item-row" [class.has-error]="item.error">
|
||||
<div class="item-info">
|
||||
<span class="file-name">{{ item.fileName }}</span>
|
||||
@if (item.error) {
|
||||
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
|
||||
} @else if (item.status === 'pending') {
|
||||
<span class="file-details pending">
|
||||
<div class="spinner-mini"></div> Analisi...
|
||||
</span>
|
||||
} @else {
|
||||
<span class="file-details">
|
||||
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
|
||||
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-controls">
|
||||
@if (!item.error) {
|
||||
<div class="qty-control">
|
||||
<label>Qtà:</label>
|
||||
<input
|
||||
@@ -57,6 +67,13 @@
|
||||
<div class="item-price">
|
||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||
</div>
|
||||
} @else if (item.status === 'pending') {
|
||||
<div class="item-price pending">
|
||||
<div class="spinner-mini"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="item-price error">-</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
background: var(--color-neutral-50);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
&.has-error {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
@@ -31,7 +36,21 @@
|
||||
}
|
||||
|
||||
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.file-details {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.color-badge {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
display: inline-block;
|
||||
}
|
||||
.file-error { font-size: 0.8rem; color: #ef4444; font-weight: 500; }
|
||||
|
||||
.item-controls {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
||||
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
|
||||
@Component({
|
||||
selector: 'app-quote-result',
|
||||
@@ -18,11 +19,13 @@ export class QuoteResultComponent {
|
||||
result = input.required<QuoteResult>();
|
||||
consult = output<void>();
|
||||
proceed = output<void>();
|
||||
itemChange = output<{id?: string, fileName: string, quantity: number}>();
|
||||
itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
|
||||
|
||||
// Local mutable state for items to handle quantity changes
|
||||
items = signal<QuoteItem[]>([]);
|
||||
|
||||
getColorHex = getColorHex;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Initialize local items when result inputs change
|
||||
@@ -44,7 +47,8 @@ export class QuoteResultComponent {
|
||||
this.itemChange.emit({
|
||||
id: this.items()[index].id,
|
||||
fileName: this.items()[index].fileName,
|
||||
quantity: qty
|
||||
quantity: qty,
|
||||
index: index
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,9 +61,11 @@ export class QuoteResultComponent {
|
||||
let weight = 0;
|
||||
|
||||
currentItems.forEach(i => {
|
||||
if (i.status === 'done' && !i.error) {
|
||||
price += i.unitPrice * i.quantity;
|
||||
time += i.unitTime * i.quantity;
|
||||
weight += i.unitWeight * i.quantity;
|
||||
}
|
||||
});
|
||||
|
||||
const hours = Math.floor(time / 3600);
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- New File List with Details -->
|
||||
@if (items().length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track item.file.name; let i = $index) {
|
||||
@for (item of items(); track item; let i = $index) {
|
||||
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
|
||||
<div class="card-header">
|
||||
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
|
||||
interface FormItem {
|
||||
id?: string;
|
||||
file: File;
|
||||
quantity: number;
|
||||
color: string;
|
||||
@@ -29,6 +30,7 @@ export class UploadFormComponent implements OnInit {
|
||||
loading = input<boolean>(false);
|
||||
uploadProgress = input<number>(0);
|
||||
submitRequest = output<QuoteRequest>();
|
||||
itemRemoved = output<{index: number, id?: string}>();
|
||||
|
||||
private estimator = inject(QuoteEstimatorService);
|
||||
private fb = inject(FormBuilder);
|
||||
@@ -75,7 +77,7 @@ export class UploadFormComponent implements OnInit {
|
||||
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||
nozzleDiameter: [0.4, Validators.required],
|
||||
infillPattern: ['grid'],
|
||||
supportEnabled: [false]
|
||||
supportEnabled: [true]
|
||||
});
|
||||
|
||||
// Listen to material changes to update variants
|
||||
@@ -112,7 +114,9 @@ export class UploadFormComponent implements OnInit {
|
||||
private setDefaults() {
|
||||
// Set Defaults if available
|
||||
if (this.materials().length > 0 && !this.form.get('material')?.value) {
|
||||
this.form.get('material')?.setValue(this.materials()[0].value);
|
||||
// Prefer PLA Basic, otherwise first available
|
||||
const pla = this.materials().find(m => m.value === 'pla_basic');
|
||||
this.form.get('material')?.setValue(pla ? pla.value : this.materials()[0].value);
|
||||
}
|
||||
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
|
||||
// Try to find 'standard' or use first
|
||||
@@ -176,6 +180,37 @@ export class UploadFormComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
updateItemQuantityAtIndex(index: number, quantity: number) {
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
if (updated[index]) {
|
||||
updated[index] = { ...updated[index], quantity };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
updateItemIds(itemsWithIds: { fileName: string, id: string }[]) {
|
||||
this.items.update(current => {
|
||||
return current.map(item => {
|
||||
const match = itemsWithIds.find(i => i.fileName === item.file.name && !i.id); // This matching is weak
|
||||
// Better: matching should be based on index if we trust order
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateItemIdsByIndex(ids: (string | undefined)[]) {
|
||||
this.items.update(current => {
|
||||
return current.map((item, i) => {
|
||||
if (ids[i]) {
|
||||
return { ...item, id: ids[i] };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectFile(file: File) {
|
||||
if (this.selectedFile() === file) {
|
||||
// toggle off? no, keep active
|
||||
@@ -206,11 +241,7 @@ export class UploadFormComponent implements OnInit {
|
||||
let val = parseInt(input.value, 10);
|
||||
if (isNaN(val) || val < 1) val = 1;
|
||||
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: val };
|
||||
return updated;
|
||||
});
|
||||
this.updateItemQuantityAtIndex(index, val);
|
||||
}
|
||||
|
||||
updateItemColor(index: number, newColor: string) {
|
||||
@@ -222,6 +253,7 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
const itemToRemove = this.items()[index];
|
||||
this.items.update(current => {
|
||||
const updated = [...current];
|
||||
const removed = updated.splice(index, 1)[0];
|
||||
@@ -230,14 +262,15 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
this.itemRemoved.emit({ index, id: itemToRemove.id });
|
||||
}
|
||||
|
||||
setFiles(files: File[]) {
|
||||
setFiles(files: File[], colors?: string[]) {
|
||||
const validItems: FormItem[] = [];
|
||||
for (const file of files) {
|
||||
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
}
|
||||
files.forEach((file, i) => {
|
||||
const color = (colors && colors[i]) ? colors[i] : 'Black';
|
||||
validItems.push({ file, quantity: 1, color: color });
|
||||
});
|
||||
|
||||
if (validItems.length > 0) {
|
||||
this.items.set(validItems);
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface QuoteItem {
|
||||
quantity: number;
|
||||
material?: string;
|
||||
color?: string;
|
||||
error?: string;
|
||||
status: 'pending' | 'done' | 'error';
|
||||
}
|
||||
|
||||
export interface QuoteResult {
|
||||
@@ -138,6 +140,13 @@ export class QuoteEstimatorService {
|
||||
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
|
||||
}
|
||||
|
||||
deleteLineItem(sessionId: string, lineItemId: string): Observable<any> {
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
return this.http.delete(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}`, { headers });
|
||||
}
|
||||
|
||||
createOrder(sessionId: string, orderDetails: any): Observable<any> {
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
@@ -145,6 +154,23 @@ export class QuoteEstimatorService {
|
||||
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
|
||||
}
|
||||
|
||||
getOrder(orderId: string): Observable<any> {
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
||||
}
|
||||
|
||||
getOrderInvoice(orderId: string): Observable<Blob> {
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||
if (request.items.length === 0) {
|
||||
@@ -163,18 +189,74 @@ export class QuoteEstimatorService {
|
||||
const sessionId = sessionRes.id;
|
||||
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||
|
||||
// Initialize items in pending state
|
||||
const currentItems: QuoteItem[] = request.items.map(item => ({
|
||||
fileName: item.file.name,
|
||||
unitPrice: 0,
|
||||
unitTime: 0,
|
||||
unitWeight: 0,
|
||||
quantity: item.quantity,
|
||||
status: 'pending',
|
||||
color: item.color || 'White' // Default color for UI
|
||||
}));
|
||||
|
||||
// Emit initial state
|
||||
const initialResult: QuoteResult = {
|
||||
sessionId: sessionId,
|
||||
items: [...currentItems],
|
||||
setupCost: sessionSetupCost,
|
||||
currency: 'CHF',
|
||||
totalPrice: 0, // Will be calculated dynamically
|
||||
totalTimeHours: 0,
|
||||
totalTimeMinutes: 0,
|
||||
totalWeight: 0,
|
||||
notes: request.notes
|
||||
};
|
||||
observer.next(initialResult);
|
||||
|
||||
// 2. Upload files to this session
|
||||
const totalItems = request.items.length;
|
||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||
const finalResponses: any[] = [];
|
||||
let completedRequests = 0;
|
||||
|
||||
const checkCompletion = () => {
|
||||
const emitUpdate = () => {
|
||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||
observer.next(avg);
|
||||
|
||||
// Helper to calculate totals for current items
|
||||
let grandTotal = 0;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
let validCount = 0;
|
||||
|
||||
currentItems.forEach(item => {
|
||||
if (item.status === 'done') {
|
||||
grandTotal += item.unitPrice * item.quantity;
|
||||
totalTime += item.unitTime * item.quantity;
|
||||
totalWeight += item.unitWeight * item.quantity;
|
||||
validCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (validCount > 0) {
|
||||
grandTotal += sessionSetupCost;
|
||||
}
|
||||
|
||||
const result: QuoteResult = {
|
||||
sessionId: sessionId,
|
||||
items: [...currentItems], // Create copy to trigger change detection
|
||||
setupCost: sessionSetupCost,
|
||||
currency: 'CHF',
|
||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: request.notes
|
||||
};
|
||||
observer.next(result);
|
||||
|
||||
if (completedRequests === totalItems) {
|
||||
finalize(finalResponses, sessionSetupCost, sessionId);
|
||||
observer.complete();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,20 +286,42 @@ export class QuoteEstimatorService {
|
||||
}).subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||
checkCompletion();
|
||||
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
|
||||
emitUpdate();
|
||||
} else if (event.type === HttpEventType.Response) {
|
||||
allProgress[index] = 100;
|
||||
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
|
||||
const resBody = event.body as any;
|
||||
|
||||
// Update item in list
|
||||
currentItems[index] = {
|
||||
id: resBody.id,
|
||||
fileName: resBody.originalFilename, // use returned filename
|
||||
unitPrice: resBody.unitPriceChf || 0,
|
||||
unitTime: resBody.printTimeSeconds || 0,
|
||||
unitWeight: resBody.materialGrams || 0,
|
||||
quantity: item.quantity, // Keep original quantity
|
||||
material: request.material,
|
||||
color: item.color || 'White',
|
||||
status: 'done'
|
||||
};
|
||||
|
||||
completedRequests++;
|
||||
checkCompletion();
|
||||
emitUpdate();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Item upload failed', err);
|
||||
finalResponses[index] = { success: false, fileName: item.file.name };
|
||||
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
|
||||
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
status: 'error',
|
||||
error: errorMsg
|
||||
};
|
||||
|
||||
allProgress[index] = 100; // Mark as done despite error
|
||||
completedRequests++;
|
||||
checkCompletion();
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -227,62 +331,6 @@ export class QuoteEstimatorService {
|
||||
observer.error('Could not initialize quote session');
|
||||
}
|
||||
});
|
||||
|
||||
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
||||
observer.next(100);
|
||||
const items: QuoteItem[] = [];
|
||||
let grandTotal = 0;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
let validCount = 0;
|
||||
|
||||
responses.forEach((res, idx) => {
|
||||
if (!res || !res.success) return;
|
||||
validCount++;
|
||||
|
||||
const unitPrice = res.unitPriceChf || 0;
|
||||
const quantity = res.originalQty || 1;
|
||||
|
||||
items.push({
|
||||
id: res.id,
|
||||
fileName: res.fileName,
|
||||
unitPrice: unitPrice,
|
||||
unitTime: res.printTimeSeconds || 0,
|
||||
unitWeight: res.materialGrams || 0,
|
||||
quantity: quantity,
|
||||
material: request.material,
|
||||
color: res.originalItem.color || 'Default'
|
||||
// Store ID if needed for updates? QuoteItem interface might need update
|
||||
// or we map it in component
|
||||
});
|
||||
|
||||
grandTotal += unitPrice * quantity;
|
||||
totalTime += (res.printTimeSeconds || 0) * quantity;
|
||||
totalWeight += (res.materialGrams || 0) * quantity;
|
||||
});
|
||||
|
||||
if (validCount === 0) {
|
||||
observer.error('All calculations failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
grandTotal += setupCost;
|
||||
|
||||
const result: QuoteResult = {
|
||||
sessionId: sessionId,
|
||||
items,
|
||||
setupCost: setupCost,
|
||||
currency: 'CHF',
|
||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: request.notes
|
||||
};
|
||||
|
||||
observer.next(result);
|
||||
observer.complete();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,7 +384,8 @@ export class QuoteEstimatorService {
|
||||
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
||||
// Backend model QuoteSession has materialCode.
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode
|
||||
color: item.colorCode,
|
||||
status: 'done'
|
||||
})),
|
||||
setupCost: session.setupCostChf,
|
||||
currency: 'CHF', // Fixed for now
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<div class="checkout-page">
|
||||
<h2 class="section-title">Checkout</h2>
|
||||
<div class="container hero">
|
||||
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="checkout-layout">
|
||||
|
||||
<!-- LEFT COLUMN: Form -->
|
||||
@@ -13,89 +16,88 @@
|
||||
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
||||
|
||||
<!-- Contact Info Card -->
|
||||
<div class="form-card">
|
||||
<div class="card-header">
|
||||
<h3>Contact Information</h3>
|
||||
<app-card class="mb-6">
|
||||
<div class="form-row">
|
||||
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
|
||||
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
|
||||
<!-- User Type Selector -->
|
||||
<div class="user-type-selector">
|
||||
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
|
||||
Private
|
||||
{{ 'CHECKOUT.PRIVATE' | translate }}
|
||||
</div>
|
||||
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
|
||||
Company
|
||||
{{ 'CHECKOUT.COMPANY' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<app-input formControlName="email" type="email" label="Email" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
|
||||
<app-input formControlName="phone" type="tel" label="Phone" [required]="true"></app-input>
|
||||
<div formGroupName="billingAddress">
|
||||
<div *ngIf="isCompany" class="company-fields">
|
||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input>
|
||||
<div class="form-row no-margin">
|
||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isCompany" class="form-row no-margin">
|
||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Billing Address Card -->
|
||||
<div class="form-card">
|
||||
<div class="card-header">
|
||||
<h3>Billing Address</h3>
|
||||
<app-card class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||
</div>
|
||||
<div class="card-content" formGroupName="billingAddress">
|
||||
<div class="form-row">
|
||||
<app-input formControlName="firstName" label="First Name" [required]="true"></app-input>
|
||||
<app-input formControlName="lastName" label="Last Name" [required]="true"></app-input>
|
||||
</div>
|
||||
|
||||
<!-- Company Name (Conditional) -->
|
||||
<app-input *ngIf="isCompany" formControlName="companyName" label="Company Name" [required]="true"></app-input>
|
||||
|
||||
<app-input formControlName="addressLine1" label="Address Line 1" [required]="true"></app-input>
|
||||
<app-input formControlName="addressLine2" label="Address Line 2 (Optional)"></app-input>
|
||||
<div formGroupName="billingAddress">
|
||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||
|
||||
<div class="form-row three-cols">
|
||||
<app-input formControlName="zip" label="ZIP Code" [required]="true"></app-input>
|
||||
<app-input formControlName="city" label="City" class="city-field" [required]="true"></app-input>
|
||||
<app-input formControlName="countryCode" label="Country" [disabled]="true" [required]="true"></app-input>
|
||||
</div>
|
||||
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
|
||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Shipping Option -->
|
||||
<div class="shipping-option">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" formControlName="shippingSameAsBilling">
|
||||
<span class="checkmark"></span>
|
||||
Shipping address same as billing
|
||||
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address Card (Conditional) -->
|
||||
<div class="form-card" *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value">
|
||||
<div class="card-header">
|
||||
<h3>Shipping Address</h3>
|
||||
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
||||
</div>
|
||||
<div class="card-content" formGroupName="shippingAddress">
|
||||
<div formGroupName="shippingAddress">
|
||||
<div class="form-row">
|
||||
<app-input formControlName="firstName" label="First Name"></app-input>
|
||||
<app-input formControlName="lastName" label="Last Name"></app-input>
|
||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
||||
</div>
|
||||
|
||||
<app-input formControlName="companyName" label="Company (Optional)"></app-input>
|
||||
<app-input formControlName="addressLine1" label="Address Line 1"></app-input>
|
||||
<app-input formControlName="zip" label="ZIP Code"></app-input>
|
||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
|
||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
|
||||
|
||||
<div class="form-row three-cols">
|
||||
<app-input formControlName="zip" label="ZIP Code"></app-input>
|
||||
<app-input formControlName="city" label="City" class="city-field"></app-input>
|
||||
<app-input formControlName="countryCode" label="Country" [disabled]="true"></app-input>
|
||||
</div>
|
||||
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
|
||||
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
|
||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<div class="actions">
|
||||
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
||||
{{ isSubmitting() ? 'Processing...' : 'Place Order' }}
|
||||
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
@@ -104,12 +106,11 @@
|
||||
|
||||
<!-- RIGHT COLUMN: Order Summary -->
|
||||
<div class="checkout-summary-section">
|
||||
<div class="form-card sticky-card">
|
||||
<div class="card-header">
|
||||
<h3>Order Summary</h3>
|
||||
<app-card class="sticky-card">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="summary-items" *ngIf="quoteSession() as session">
|
||||
<div class="summary-item" *ngFor="let item of session.items">
|
||||
<div class="item-details">
|
||||
@@ -130,21 +131,23 @@
|
||||
|
||||
<div class="summary-totals" *ngIf="quoteSession() as session">
|
||||
<div class="total-row">
|
||||
<span>Subtotal</span>
|
||||
<span>{{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }}</span>
|
||||
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
|
||||
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>Setup Fee</span>
|
||||
<span>{{ session.setupCostChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="total-row grand-total">
|
||||
<span>Total</span>
|
||||
<span>{{ session.totalPrice | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
||||
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
||||
<span>{{ 9.0 | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="grand-total-row">
|
||||
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
||||
<span>{{ (session.grandTotalChf + 9.0) | currency:'CHF' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
.checkout-page {
|
||||
padding: 3rem 1rem;
|
||||
max-width: 1200px;
|
||||
.hero {
|
||||
padding: var(--space-12) 0 var(--space-8);
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.checkout-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
margin-bottom: var(--space-12);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
gap: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
.card-header-simple {
|
||||
margin-bottom: var(--space-6);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-bottom: var(--space-6);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
|
||||
.card-header {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-subtle);
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
@media(min-width: 768px) {
|
||||
flex-direction: row;
|
||||
& > * { flex: 1; }
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.three-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-template-columns: 1.5fr 2fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
app-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
&.three-cols {
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
app-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* User Type Selector Styles */
|
||||
/* User Type Selector - Matching Contact Form Style */
|
||||
.user-type-selector {
|
||||
display: flex;
|
||||
background-color: var(--color-bg-subtle);
|
||||
background-color: var(--color-neutral-100);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
margin-bottom: var(--space-4);
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
@@ -105,8 +105,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.company-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--color-border);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.shipping-option {
|
||||
margin: var(--space-6) 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Custom Checkbox */
|
||||
@@ -114,9 +126,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
padding-left: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -142,10 +155,10 @@
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-color: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -153,12 +166,12 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 6px;
|
||||
top: 2px;
|
||||
left: 7px;
|
||||
top: 3px;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
border: solid #000;
|
||||
border-width: 0 2.5px 2.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
@@ -168,40 +181,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.checkout-summary-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sticky-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Inherits styles from .form-card */
|
||||
top: var(--space-6);
|
||||
}
|
||||
|
||||
.summary-items {
|
||||
margin-bottom: var(--space-6);
|
||||
max-height: 400px;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-2);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3) 0;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
&:first-child { padding-top: 0; }
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.item-details {
|
||||
flex: 1;
|
||||
|
||||
.item-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: var(--space-1);
|
||||
word-break: break-all;
|
||||
color: var(--color-text);
|
||||
@@ -215,8 +235,8 @@
|
||||
color: var(--color-text-muted);
|
||||
|
||||
.color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -234,13 +254,13 @@
|
||||
font-weight: 600;
|
||||
margin-left: var(--space-3);
|
||||
white-space: nowrap;
|
||||
color: var(--color-heading);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-totals {
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
@@ -248,45 +268,39 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.grand-total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text);
|
||||
|
||||
&.grand-total {
|
||||
color: var(--color-heading);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none; // Handled by border-top in grand-total
|
||||
font-size: 1.5rem;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: var(--space-6);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-8);
|
||||
|
||||
app-button {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 900px) {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-subtle);
|
||||
color: var(--color-error);
|
||||
background: #fef2f2;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--color-danger);
|
||||
border: 1px solid #fee2e2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout',
|
||||
@@ -12,11 +14,13 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
AppInputComponent,
|
||||
AppButtonComponent
|
||||
AppButtonComponent,
|
||||
AppCardComponent
|
||||
],
|
||||
templateUrl: './checkout.component.html',
|
||||
styleUrls: ['./checkout.component.scss']
|
||||
styleUrl: './checkout.component.scss'
|
||||
})
|
||||
export class CheckoutComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
||||
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
||||
<textarea formControlName="message" class="form-control" rows="10"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
|
||||
@@ -1,21 +1,113 @@
|
||||
<div class="payment-container">
|
||||
<mat-card class="payment-card">
|
||||
<mat-card-header>
|
||||
<mat-icon mat-card-avatar>payment</mat-icon>
|
||||
<mat-card-title>Payment Integration</mat-card-title>
|
||||
<mat-card-subtitle>Order #{{ orderId }}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="coming-soon">
|
||||
<h3>Coming Soon</h3>
|
||||
<p>The online payment system is currently under development.</p>
|
||||
<p>Your order has been saved. Please contact us to arrange payment.</p>
|
||||
<div class="container hero">
|
||||
<h1>{{ 'PAYMENT.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="payment-layout" *ngIf="order() as o">
|
||||
|
||||
<div class="payment-main">
|
||||
<app-card class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="payment-selection">
|
||||
<div class="methods-grid">
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="selectedPaymentMethod === 'twint'"
|
||||
(click)="selectPayment('twint')">
|
||||
<span class="method-name">TWINT</span>
|
||||
</div>
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="selectedPaymentMethod === 'bill'"
|
||||
(click)="selectPayment('bill')">
|
||||
<span class="method-name">QR Bill / Bank Transfer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TWINT Details -->
|
||||
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
|
||||
<div class="details-header">
|
||||
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
|
||||
</div>
|
||||
<div class="qr-placeholder">
|
||||
<div class="qr-box">
|
||||
<span>QR CODE</span>
|
||||
</div>
|
||||
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
|
||||
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Bill Details -->
|
||||
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
|
||||
<div class="details-header">
|
||||
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
||||
</div>
|
||||
<div class="bank-details">
|
||||
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
|
||||
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
|
||||
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
|
||||
|
||||
<div class="qr-bill-actions">
|
||||
<app-button variant="outline" (click)="downloadInvoice()">
|
||||
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
|
||||
{{ 'PAYMENT.CONFIRM' | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
<div class="payment-summary">
|
||||
<app-card class="sticky-card">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
|
||||
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary-totals">
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
|
||||
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
|
||||
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
|
||||
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="grand-total-row">
|
||||
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
|
||||
<span>{{ o.totalChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading()" class="loading-state">
|
||||
<app-card>
|
||||
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error()" class="error-message">
|
||||
<app-card>
|
||||
<p>{{ error() }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions align="end">
|
||||
<button mat-raised-button color="primary" (click)="completeOrder()">
|
||||
Simulate Payment Completion
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,195 @@
|
||||
.payment-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
padding: 2rem;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
.hero {
|
||||
padding: var(--space-12) 0 var(--space-8);
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.payment-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
margin-bottom: var(--space-12);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header-simple {
|
||||
margin-bottom: var(--space-6);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: #555;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-selection {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.methods-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.type-option {
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-bg-card);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-brand);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-brand);
|
||||
background-color: var(--color-neutral-100);
|
||||
color: #000;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
.details-header {
|
||||
margin-bottom: var(--space-4);
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
.qr-box {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background-color: white;
|
||||
border: 2px solid var(--color-neutral-900);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.bank-details {
|
||||
p {
|
||||
color: #777;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #3f51b5;
|
||||
.qr-bill-actions {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.sticky-card {
|
||||
position: sticky;
|
||||
top: var(--space-6);
|
||||
}
|
||||
|
||||
.summary-totals {
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.grand-total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
|
||||
.error-message, .loading-state {
|
||||
margin-top: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,76 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-payment',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
|
||||
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
|
||||
templateUrl: './payment.component.html',
|
||||
styleUrl: './payment.component.scss'
|
||||
})
|
||||
export class PaymentComponent implements OnInit {
|
||||
orderId: string | null = null;
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private quoteService = inject(QuoteEstimatorService);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {}
|
||||
orderId: string | null = null;
|
||||
selectedPaymentMethod: 'twint' | 'bill' | null = null;
|
||||
order = signal<any>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
||||
if (this.orderId) {
|
||||
this.loadOrder();
|
||||
} else {
|
||||
this.error.set('Order ID not found.');
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadOrder() {
|
||||
if (!this.orderId) return;
|
||||
this.quoteService.getOrder(this.orderId).subscribe({
|
||||
next: (order) => {
|
||||
this.order.set(order);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load order', err);
|
||||
this.error.set('Failed to load order details.');
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectPayment(method: 'twint' | 'bill'): void {
|
||||
this.selectedPaymentMethod = method;
|
||||
}
|
||||
|
||||
downloadInvoice() {
|
||||
if (!this.orderId) return;
|
||||
this.quoteService.getOrderInvoice(this.orderId).subscribe({
|
||||
next: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `invoice-${this.orderId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => console.error('Failed to download invoice', err)
|
||||
});
|
||||
}
|
||||
|
||||
completeOrder(): void {
|
||||
// Simulate payment completion
|
||||
alert('Payment Simulated! Order marked as PAID.');
|
||||
// Here you would call the backend to mark as paid if we had that endpoint ready
|
||||
// For now, redirect home or show success
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"CTA_START": "Start Now",
|
||||
"BUSINESS": "Business",
|
||||
"PRIVATE": "Private",
|
||||
"MODE_EASY": "Quick",
|
||||
"MODE_EASY": "Easy Print",
|
||||
"MODE_ADVANCED": "Advanced",
|
||||
"UPLOAD_LABEL": "Drag your 3D file here",
|
||||
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
|
||||
@@ -61,6 +61,9 @@
|
||||
"ORDER": "Order Now",
|
||||
"CONSULT": "Request Consultation",
|
||||
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
||||
"ERROR_UPLOAD_FAILED": "File upload failed. Please try again.",
|
||||
"ERROR_VIRUS_DETECTED": "File removed (virus detected)",
|
||||
"ERROR_SLICING_FAILED": "Slicing error (complex geometry?)",
|
||||
"NEW_QUOTE": "Calculate New Quote",
|
||||
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
|
||||
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
|
||||
@@ -148,5 +151,50 @@
|
||||
"SUCCESS_TITLE": "Message Sent Successfully",
|
||||
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
|
||||
"SEND_ANOTHER": "Send Another Message"
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
|
||||
"CONTACT_INFO": "Contact Information",
|
||||
"BILLING_ADDR": "Billing Address",
|
||||
"SHIPPING_ADDR": "Shipping Address",
|
||||
"SHIPPING_SAME": "Shipping address same as billing",
|
||||
"ORDER_SUMMARY": "Order Summary",
|
||||
"SUBTOTAL": "Subtotal",
|
||||
"SETUP_FEE": "Setup Fee",
|
||||
"SHIPPING": "Shipping",
|
||||
"TOTAL": "Total",
|
||||
"PLACE_ORDER": "Place Order",
|
||||
"PROCESSING": "Processing...",
|
||||
"PRIVATE": "Private",
|
||||
"COMPANY": "Company",
|
||||
"FIRST_NAME": "First Name",
|
||||
"LAST_NAME": "Last Name",
|
||||
"EMAIL": "Email",
|
||||
"PHONE": "Phone",
|
||||
"COMPANY_NAME": "Company Name",
|
||||
"ADDRESS_1": "Address Line 1",
|
||||
"ADDRESS_2": "Address Line 2 (Optional)",
|
||||
"ZIP": "ZIP Code",
|
||||
"CITY": "City",
|
||||
"COUNTRY": "Country"
|
||||
},
|
||||
"PAYMENT": {
|
||||
"TITLE": "Payment",
|
||||
"METHOD": "Payment Method",
|
||||
"TWINT_TITLE": "Pay with TWINT",
|
||||
"TWINT_DESC": "Scan the code with your TWINT app",
|
||||
"BANK_TITLE": "Bank Transfer",
|
||||
"BANK_OWNER": "Owner",
|
||||
"BANK_IBAN": "IBAN",
|
||||
"BANK_REF": "Reference",
|
||||
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
||||
"CONFIRM": "Confirm Order",
|
||||
"SUMMARY_TITLE": "Order Summary",
|
||||
"SUBTOTAL": "Subtotal",
|
||||
"SHIPPING": "Shipping",
|
||||
"SETUP_FEE": "Setup Fee",
|
||||
"TOTAL": "Total",
|
||||
"LOADING": "Loading order details..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"CTA_START": "Inizia Ora",
|
||||
"BUSINESS": "Aziende",
|
||||
"PRIVATE": "Privati",
|
||||
"MODE_EASY": "Base",
|
||||
"MODE_EASY": "Stampa Facile",
|
||||
"MODE_ADVANCED": "Avanzata",
|
||||
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
||||
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
|
||||
@@ -40,6 +40,9 @@
|
||||
"ORDER": "Ordina Ora",
|
||||
"CONSULT": "Richiedi Consulenza",
|
||||
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
||||
"ERROR_UPLOAD_FAILED": "Caricamento file fallito. Riprova.",
|
||||
"ERROR_VIRUS_DETECTED": "File rimosso (virus rilevato)",
|
||||
"ERROR_SLICING_FAILED": "Errore slicing (geometria complessa?)",
|
||||
"NEW_QUOTE": "Calcola Nuovo Preventivo",
|
||||
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
|
||||
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
|
||||
@@ -127,5 +130,50 @@
|
||||
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
|
||||
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
|
||||
"SEND_ANOTHER": "Invia un altro messaggio"
|
||||
},
|
||||
"CHECKOUT": {
|
||||
"TITLE": "Checkout",
|
||||
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
|
||||
"CONTACT_INFO": "Informazioni di Contatto",
|
||||
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
||||
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
||||
"SHIPPING_SAME": "Indirizzo di spedizione uguale a quello di fatturazione",
|
||||
"ORDER_SUMMARY": "Riepilogo Ordine",
|
||||
"SUBTOTAL": "Subtotale",
|
||||
"SETUP_FEE": "Costo Setup",
|
||||
"SHIPPING": "Spedizione",
|
||||
"TOTAL": "Totale",
|
||||
"PLACE_ORDER": "Conferma Ordine",
|
||||
"PROCESSING": "Elaborazione...",
|
||||
"PRIVATE": "Privato",
|
||||
"COMPANY": "Azienda",
|
||||
"FIRST_NAME": "Nome",
|
||||
"LAST_NAME": "Cognome",
|
||||
"EMAIL": "Email",
|
||||
"PHONE": "Telefono",
|
||||
"COMPANY_NAME": "Nome Azienda",
|
||||
"ADDRESS_1": "Indirizzo riga 1",
|
||||
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
|
||||
"ZIP": "CAP",
|
||||
"CITY": "Città",
|
||||
"COUNTRY": "Paese"
|
||||
},
|
||||
"PAYMENT": {
|
||||
"TITLE": "Pagamento",
|
||||
"METHOD": "Metodo di Pagamento",
|
||||
"TWINT_TITLE": "Paga con TWINT",
|
||||
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
|
||||
"BANK_TITLE": "Bonifico Bancario",
|
||||
"BANK_OWNER": "Titolare",
|
||||
"BANK_IBAN": "IBAN",
|
||||
"BANK_REF": "Riferimento",
|
||||
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
||||
"CONFIRM": "Conferma Ordine",
|
||||
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||
"SUBTOTAL": "Subtotale",
|
||||
"SHIPPING": "Spedizione",
|
||||
"SETUP_FEE": "Costo Setup",
|
||||
"TOTAL": "Totale",
|
||||
"LOADING": "Caricamento dettagli ordine..."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user