From becb15da7316e724d05abc769bb2eaea2ee68ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sat, 14 Feb 2026 16:47:49 +0100 Subject: [PATCH] fix invoice and support costs --- backend/build.gradle | 4 + .../controller/OrderController.java | 156 +--------- .../controller/QuoteController.java | 5 +- .../controller/QuoteSessionController.java | 18 +- .../printcalculator/dto/PrintSettingsDto.java | 2 +- .../com/printcalculator/entity/Order.java | 1 + .../com/printcalculator/model/PrintStats.java | 33 +- .../printcalculator/service/GCodeParser.java | 22 +- .../service/InvoicePdfRenderingService.java | 3 +- .../printcalculator/service/OrderService.java | 292 ++++++++++++++++++ .../service/QrBillService.java | 68 ++++ .../service/QuoteCalculator.java | 14 +- .../src/main/resources/templates/invoice.html | 3 +- .../upload-form/upload-form.component.ts | 2 +- 14 files changed, 461 insertions(+), 162 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/service/OrderService.java create mode 100644 backend/src/main/java/com/printcalculator/service/QrBillService.java diff --git a/backend/build.gradle b/backend/build.gradle index aef0eae..abd1641 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -35,6 +35,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' + + + } tasks.named('test') { diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index d40cb9d..38781c2 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -4,6 +4,8 @@ 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; @@ -28,6 +30,7 @@ import java.util.stream.Collectors; @RequestMapping("/api/orders") public class OrderController { + private final OrderService orderService; private final OrderRepository orderRepo; private final OrderItemRepository orderItemRepo; private final QuoteSessionRepository quoteSessionRepo; @@ -35,15 +38,19 @@ public class OrderController { private final CustomerRepository customerRepo; private final StorageService storageService; private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; - public OrderController(OrderRepository orderRepo, + public OrderController(OrderService orderService, + OrderRepository orderRepo, OrderItemRepository orderItemRepo, QuoteSessionRepository quoteSessionRepo, QuoteLineItemRepository quoteLineItemRepo, CustomerRepository customerRepo, StorageService storageService, - InvoicePdfRenderingService invoiceService) { + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService) { + this.orderService = orderService; this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; @@ -51,6 +58,7 @@ public class OrderController { this.customerRepo = customerRepo; this.storageService = storageService; this.invoiceService = invoiceService; + this.qrBillService = qrBillService; } @@ -61,144 +69,9 @@ public class OrderController { @PathVariable UUID quoteSessionId, @RequestBody com.printcalculator.dto.CreateOrderRequest request ) { - QuoteSession session = quoteSessionRepo.findById(quoteSessionId) - .orElseThrow(() -> new RuntimeException("Quote Session not found")); - - if (session.getConvertedOrderId() != null) { - return ResponseEntity.badRequest().body(null); - } - - 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 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); - - 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(); - } - } - - orderItemRepo.save(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); - - order = orderRepo.save(order); - List finalItems = orderItemRepo.findByOrder_Id(order.getId()); - - return ResponseEntity.ok(convertToDto(order, finalItems)); + Order order = orderService.createOrderFromQuote(quoteSessionId, request); + List items = orderItemRepo.findByOrder_Id(order.getId()); + return ResponseEntity.ok(convertToDto(order, items)); } @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -295,7 +168,8 @@ public class OrderController { vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf())); vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie."); - byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars); + String qrBillSvg = new String(qrBillService.generateQrBillSvg(order)); + byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"") diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 448c150..0947e61 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -44,7 +44,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 = "true") Boolean supportEnabled ) throws IOException { // ... process selection logic ... @@ -72,6 +72,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) { diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 84f7bcd..78d0528 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -72,7 +72,7 @@ public class QuoteSessionController { // Default material/settings will be set when items are added or updated? // For now set safe defaults session.setMaterialCode("pla_basic"); - session.setSupportsEnabled(false); + session.setSupportsEnabled(true); session.setCreatedAt(OffsetDateTime.now()); session.setExpiresAt(OffsetDateTime.now().plusDays(30)); @@ -178,7 +178,14 @@ public class QuoteSessionController { 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 (Boolean.TRUE.equals(settings.getSupportsEnabled())) processOverrides.put("enable_support", "1"); + 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"); + } + } Map machineOverrides = new HashMap<>(); if (settings.getNozzleDiameter() != null) { @@ -207,8 +214,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 @@ -267,13 +274,14 @@ public class QuoteSessionController { if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4); break; } + if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(true); } 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); + if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(true); } } diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java index 307fa15..54ff0cb 100644 --- a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -18,7 +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; } diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java index 9ca1ea6..92270bb 100644 --- a/backend/src/main/java/com/printcalculator/entity/Order.java +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -410,4 +410,5 @@ public class Order { this.paidAt = paidAt; } + } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/model/PrintStats.java b/backend/src/main/java/com/printcalculator/model/PrintStats.java index 898aed2..0115790 100644 --- a/backend/src/main/java/com/printcalculator/model/PrintStats.java +++ b/backend/src/main/java/com/printcalculator/model/PrintStats.java @@ -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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/GCodeParser.java b/backend/src/main/java/com/printcalculator/service/GCodeParser.java index ea414d0..2b61b44 100644 --- a/backend/src/main/java/com/printcalculator/service/GCodeParser.java +++ b/backend/src/main/java/com/printcalculator/service/GCodeParser.java @@ -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) { diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java index d0e0682..de1faaa 100644 --- a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -20,10 +20,11 @@ public class InvoicePdfRenderingService { this.thymeleafTemplateEngine = thymeleafTemplateEngine; } - public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables) { + public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables, String qrBillSvg) { try { Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY); thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables); + thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg); String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData); diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java new file mode 100644 index 0000000..f08d8f3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -0,0 +1,292 @@ +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 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 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 items) { + try { + // 1. Generate QR Bill + byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order); + String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8); + + // Save QR Bill SVG + String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg"; + saveFileBytes(qrBillSvgBytes, qrRelativePath); + + // 2. Prepare Invoice Variables + Map 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> invoiceLineItems = items.stream().map(i -> { + Map 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 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 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"; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/QrBillService.java new file mode 100644 index 0000000..093739f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/QrBillService.java @@ -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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index 432c62f..8fadca4 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -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 diff --git a/backend/src/main/resources/templates/invoice.html b/backend/src/main/resources/templates/invoice.html index dc8a549..c61c613 100644 --- a/backend/src/main/resources/templates/invoice.html +++ b/backend/src/main/resources/templates/invoice.html @@ -76,8 +76,9 @@ Pagamento entro 7 giorni. Grazie. - +
+
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 1c98a5f..9258067 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -75,7 +75,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