From 1b3f0b16ff65f0c65aba00442f6a575a4070648e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 12:03:09 +0100 Subject: [PATCH 1/4] feat(back-end and front-end) cad bill with order --- .../controller/OrderController.java | 5 + .../controller/QuoteSessionController.java | 172 ++++++++++-------- .../admin/AdminOperationsController.java | 139 +++++++++++++- .../admin/AdminOrderController.java | 5 + .../dto/AdminCadInvoiceCreateRequest.java | 52 ++++++ .../dto/AdminCadInvoiceDto.java | 143 +++++++++++++++ .../dto/AdminQuoteSessionDto.java | 37 ++++ .../com/printcalculator/dto/OrderDto.java | 20 ++ .../com/printcalculator/entity/Order.java | 57 ++++++ .../printcalculator/entity/QuoteSession.java | 35 +++- .../repository/QuoteSessionRepository.java | 4 +- .../service/InvoicePdfRenderingService.java | 17 ++ .../printcalculator/service/OrderService.java | 80 +++----- .../service/QuoteSessionTotalsService.java | 124 +++++++++++++ .../service/SessionCleanupService.java | 5 +- .../QuoteSessionTotalsServiceTest.java | 84 +++++++++ db.sql | 61 ++++++- frontend/src/app/app.routes.ts | 7 + .../src/app/core/layout/navbar.component.html | 2 +- .../src/app/core/layout/navbar.component.ts | 3 + .../src/app/features/admin/admin.routes.ts | 7 + .../pages/admin-cad-invoices.component.html | 128 +++++++++++++ .../pages/admin-cad-invoices.component.scss | 140 ++++++++++++++ .../pages/admin-cad-invoices.component.ts | 157 ++++++++++++++++ .../admin-contact-requests.component.scss | 14 +- .../admin/pages/admin-sessions.component.html | 12 ++ .../admin/pages/admin-sessions.component.scss | 16 ++ .../admin/pages/admin-sessions.component.ts | 36 ++++ .../admin/pages/admin-shell.component.html | 3 + .../services/admin-operations.service.ts | 51 ++++++ .../admin/services/admin-orders.service.ts | 5 + .../calculator/calculator-page.component.html | 7 +- .../calculator/calculator-page.component.scss | 5 + .../calculator/calculator-page.component.ts | 13 +- .../quote-result/quote-result.component.html | 6 + .../quote-result/quote-result.component.ts | 3 +- .../upload-form/upload-form.component.html | 6 + .../upload-form/upload-form.component.ts | 30 ++- .../services/quote-estimator.service.ts | 4 + .../features/checkout/checkout.component.html | 17 ++ .../features/checkout/checkout.component.scss | 12 ++ .../features/checkout/checkout.component.ts | 16 ++ .../app/features/order/order.component.html | 4 + 43 files changed, 1594 insertions(+), 150 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceCreateRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceDto.java create mode 100644 backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java create mode 100644 backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java create mode 100644 frontend/src/app/features/admin/pages/admin-cad-invoices.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-cad-invoices.component.ts diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index e002fb9..3dd4874 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -301,6 +301,11 @@ public class OrderController { dto.setShippingCostChf(order.getShippingCostChf()); dto.setDiscountChf(order.getDiscountChf()); dto.setSubtotalChf(order.getSubtotalChf()); + dto.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); dto.setTotalChf(order.getTotalChf()); dto.setCreatedAt(order.getCreatedAt()); dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 6785d25..86d741c 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -15,6 +15,7 @@ import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.QuoteCalculator; +import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.SlicerService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -42,6 +43,9 @@ import java.util.Optional; import java.util.Locale; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; +import org.springframework.web.server.ResponseStatusException; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; @RestController @RequestMapping("/api/quote-sessions") @@ -59,6 +63,7 @@ public class QuoteSessionController { private final OrcaProfileResolver orcaProfileResolver; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.service.ClamAVService clamAVService; + private final QuoteSessionTotalsService quoteSessionTotalsService; public QuoteSessionController(QuoteSessionRepository sessionRepo, QuoteLineItemRepository lineItemRepo, @@ -69,7 +74,8 @@ public class QuoteSessionController { FilamentVariantRepository variantRepo, OrcaProfileResolver orcaProfileResolver, com.printcalculator.repository.PricingPolicyRepository pricingRepo, - com.printcalculator.service.ClamAVService clamAVService) { + com.printcalculator.service.ClamAVService clamAVService, + QuoteSessionTotalsService quoteSessionTotalsService) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; this.slicerService = slicerService; @@ -80,6 +86,7 @@ public class QuoteSessionController { this.orcaProfileResolver = orcaProfileResolver; this.pricingRepo = pricingRepo; this.clamAVService = clamAVService; + this.quoteSessionTotalsService = quoteSessionTotalsService; } // 1. Start a new empty session @@ -121,6 +128,9 @@ public class QuoteSessionController { // Helper to add item private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IllegalArgumentException("File is empty"); + if ("CONVERTED".equals(session.getStatus())) { + throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session"); + } // Scan for virus clamAVService.scan(file.getInputStream()); @@ -148,8 +158,14 @@ public class QuoteSessionController { Path convertedPersistentPath = null; try { - // Apply Basic/Advanced Logic - applyPrintSettings(settings); + boolean cadSession = "CAD_ACTIVE".equals(session.getStatus()); + + // In CAD sessions, print settings are locked server-side. + if (cadSession) { + enforceCadPrintSettings(session, settings); + } else { + applyPrintSettings(settings); + } BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); @@ -159,14 +175,27 @@ public class QuoteSessionController { // Resolve selected filament variant FilamentVariant selectedVariant = resolveFilamentVariant(settings); + if (cadSession + && session.getMaterialCode() != null + && selectedVariant.getFilamentMaterialType() != null + && selectedVariant.getFilamentMaterialType().getMaterialCode() != null) { + String lockedMaterial = normalizeRequestedMaterialCode(session.getMaterialCode()); + String selectedMaterial = normalizeRequestedMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); + if (!lockedMaterial.equals(selectedMaterial)) { + throw new ResponseStatusException(BAD_REQUEST, "Selected filament does not match locked CAD material"); + } + } + // Update session global settings from the most recent item added - session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); - session.setNozzleDiameterMm(nozzleDiameter); - session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); - session.setInfillPattern(settings.getInfillPattern()); - session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); - session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); - sessionRepo.save(session); + if (!cadSession) { + session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); + session.setNozzleDiameterMm(nozzleDiameter); + session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); + session.setInfillPattern(settings.getInfillPattern()); + session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); + session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); + sessionRepo.save(session); + } OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); String machineProfile = profiles.machineProfileName(); @@ -290,6 +319,16 @@ public class QuoteSessionController { } } + private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.PrintSettingsDto settings) { + settings.setComplexityMode("ADVANCED"); + settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA"); + settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4); + settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2); + settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid"); + settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0); + settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled())); + } + private PrinterMachine resolvePrinterMachine(Long printerMachineId) { if (printerMachineId != null) { PrinterMachine selected = machineRepo.findById(printerMachineId) @@ -344,6 +383,32 @@ public class QuoteSessionController { .replaceAll("\\s+", " "); } + private int parsePositiveQuantity(Object raw) { + if (raw == null) { + throw new ResponseStatusException(BAD_REQUEST, "Quantity is required"); + } + + int quantity; + if (raw instanceof Number number) { + double numericValue = number.doubleValue(); + if (!Double.isFinite(numericValue)) { + throw new ResponseStatusException(BAD_REQUEST, "Quantity must be a finite number"); + } + quantity = (int) Math.floor(numericValue); + } else { + try { + quantity = Integer.parseInt(String.valueOf(raw).trim()); + } catch (NumberFormatException ex) { + throw new ResponseStatusException(BAD_REQUEST, "Quantity must be an integer"); + } + } + + if (quantity < 1) { + throw new ResponseStatusException(BAD_REQUEST, "Quantity must be >= 1"); + } + return quantity; + } + // 3. Update Line Item @PatchMapping("/line-items/{lineItemId}") @Transactional @@ -353,12 +418,20 @@ public class QuoteSessionController { ) { QuoteLineItem item = lineItemRepo.findById(lineItemId) .orElseThrow(() -> new RuntimeException("Item not found")); + + QuoteSession session = item.getQuoteSession(); + if ("CONVERTED".equals(session.getStatus())) { + throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session"); + } if (updates.containsKey("quantity")) { - item.setQuantity((Integer) updates.get("quantity")); + item.setQuantity(parsePositiveQuantity(updates.get("quantity"))); } if (updates.containsKey("color_code")) { - item.setColorCode((String) updates.get("color_code")); + Object colorValue = updates.get("color_code"); + if (colorValue != null) { + item.setColorCode(String.valueOf(colorValue)); + } } // Recalculate price if needed? @@ -394,25 +467,7 @@ public class QuoteSessionController { .orElseThrow(() -> new RuntimeException("Session not found")); List items = lineItemRepo.findByQuoteSessionId(id); - - // Calculate Totals and global session hours - BigDecimal itemsTotal = BigDecimal.ZERO; - BigDecimal totalSeconds = BigDecimal.ZERO; - - for (QuoteLineItem item : items) { - BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity())); - itemsTotal = itemsTotal.add(lineTotal); - - if (item.getPrintTimeSeconds() != null) { - totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()))); - } - } - - BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); - BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); - - itemsTotal = itemsTotal.add(globalMachineCost); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); // Map items to DTO to embed distributed machine cost List> itemsDto = new ArrayList<>(); @@ -428,57 +483,28 @@ public class QuoteSessionController { dto.put("status", item.getStatus()); dto.put("convertedStoredPath", extractConvertedStoredPath(item)); - BigDecimal unitPrice = item.getUnitPriceChf(); - if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { - BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())); - BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); - BigDecimal itemMachineCost = globalMachineCost.multiply(share); - BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP); + BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO; + int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1; + if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity)); + BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP); unitPrice = unitPrice.add(unitMachineCost); } dto.put("unitPriceChf", unitPrice); itemsDto.add(dto); } - BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; - - // Calculate shipping cost based on dimensions - boolean exceedsBaseSize = false; - for (QuoteLineItem item : items) { - BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; - BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; - BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; - - BigDecimal[] dims = {x, y, z}; - java.util.Arrays.sort(dims); - - if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || - dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || - dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { - exceedsBaseSize = true; - break; - } - } - int totalQuantity = items.stream() - .mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1) - .sum(); - - BigDecimal shippingCostChf; - if (exceedsBaseSize) { - shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00); - } else { - shippingCostChf = BigDecimal.valueOf(2.00); - } - - BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf); - Map response = new HashMap<>(); response.put("session", session); response.put("items", itemsDto); - response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost - response.put("shippingCostChf", shippingCostChf); - response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now) - response.put("grandTotalChf", grandTotal); + response.put("printItemsTotalChf", totals.printItemsTotalChf()); + response.put("cadTotalChf", totals.cadTotalChf()); + response.put("itemsTotalChf", totals.itemsTotalChf()); + response.put("shippingCostChf", totals.shippingCostChf()); + response.put("globalMachineCostChf", totals.globalMachineCostChf()); + response.put("grandTotalChf", totals.grandTotalChf()); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java index f026884..52b8149 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -3,6 +3,8 @@ package com.printcalculator.controller.admin; import com.printcalculator.dto.AdminContactRequestDto; import com.printcalculator.dto.AdminContactRequestAttachmentDto; import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminCadInvoiceCreateRequest; +import com.printcalculator.dto.AdminCadInvoiceDto; import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; @@ -10,13 +12,18 @@ import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariantStockKg; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantStockKgRepository; import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteSessionTotalsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.Resource; @@ -31,6 +38,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -39,6 +47,7 @@ import org.springframework.web.server.ResponseStatusException; import java.io.IOException; import java.io.UncheckedIOException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.net.MalformedURLException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -75,7 +84,10 @@ public class AdminOperationsController { private final CustomQuoteRequestRepository customQuoteRequestRepo; private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; private final OrderRepository orderRepo; + private final PricingPolicyRepository pricingRepo; + private final QuoteSessionTotalsService quoteSessionTotalsService; public AdminOperationsController( FilamentVariantStockKgRepository filamentStockRepo, @@ -83,14 +95,20 @@ public class AdminOperationsController { CustomQuoteRequestRepository customQuoteRequestRepo, CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, QuoteSessionRepository quoteSessionRepo, - OrderRepository orderRepo + QuoteLineItemRepository quoteLineItemRepo, + OrderRepository orderRepo, + PricingPolicyRepository pricingRepo, + QuoteSessionTotalsService quoteSessionTotalsService ) { this.filamentStockRepo = filamentStockRepo; this.filamentVariantRepo = filamentVariantRepo; this.customQuoteRequestRepo = customQuoteRequestRepo; this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; this.orderRepo = orderRepo; + this.pricingRepo = pricingRepo; + this.quoteSessionTotalsService = quoteSessionTotalsService; } @GetMapping("/filament-stock") @@ -279,6 +297,83 @@ public class AdminOperationsController { return ResponseEntity.ok(response); } + @GetMapping("/cad-invoices") + public ResponseEntity> getCadInvoices() { + List response = quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) + .stream() + .filter(this::isCadSessionRecord) + .map(this::toCadInvoiceDto) + .toList(); + return ResponseEntity.ok(response); + } + + @PostMapping("/cad-invoices") + @Transactional + public ResponseEntity createOrUpdateCadInvoice( + @RequestBody AdminCadInvoiceCreateRequest payload + ) { + if (payload == null || payload.getCadHours() == null) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours is required"); + } + + BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP); + if (cadHours.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0"); + } + + BigDecimal cadRate = payload.getCadHourlyRateChf(); + if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) { + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + cadRate = policy != null && policy.getCadCostChfPerHour() != null + ? policy.getCadCostChfPerHour() + : BigDecimal.ZERO; + } + cadRate = cadRate.setScale(2, RoundingMode.HALF_UP); + + QuoteSession session; + if (payload.getSessionId() != null) { + session = quoteSessionRepo.findById(payload.getSessionId()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + } else { + session = new QuoteSession(); + session.setStatus("CAD_ACTIVE"); + session.setPricingVersion("v1"); + session.setMaterialCode("PLA"); + session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); + session.setLayerHeightMm(BigDecimal.valueOf(0.2)); + session.setInfillPattern("grid"); + session.setInfillPercent(20); + session.setSupportsEnabled(false); + session.setSetupCostChf(BigDecimal.ZERO); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + } + + if ("CONVERTED".equals(session.getStatus())) { + throw new ResponseStatusException(CONFLICT, "Session already converted to order"); + } + + if (payload.getSourceRequestId() != null) { + if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) { + throw new ResponseStatusException(NOT_FOUND, "Source request not found"); + } + session.setSourceRequestId(payload.getSourceRequestId()); + } else { + session.setSourceRequestId(null); + } + + session.setStatus("CAD_ACTIVE"); + session.setCadHours(cadHours); + session.setCadHourlyRateChf(cadRate); + if (payload.getNotes() != null) { + String trimmedNotes = payload.getNotes().trim(); + session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes); + } + + QuoteSession saved = quoteSessionRepo.save(session); + return ResponseEntity.ok(toCadInvoiceDto(saved)); + } + @DeleteMapping("/sessions/{sessionId}") @Transactional public ResponseEntity deleteQuoteSession(@PathVariable UUID sessionId) { @@ -347,6 +442,48 @@ public class AdminOperationsController { dto.setCreatedAt(session.getCreatedAt()); dto.setExpiresAt(session.getExpiresAt()); dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours()); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf()); + dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session)); + return dto; + } + + private boolean isCadSessionRecord(QuoteSession session) { + if ("CAD_ACTIVE".equals(session.getStatus())) { + return true; + } + if (!"CONVERTED".equals(session.getStatus())) { + return false; + } + BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; + return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null; + } + + private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) { + List items = quoteLineItemRepo.findByQuoteSessionId(session.getId()); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + + AdminCadInvoiceDto dto = new AdminCadInvoiceDto(); + dto.setSessionId(session.getId()); + dto.setSessionStatus(session.getStatus()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); + dto.setCadTotalChf(totals.cadTotalChf()); + dto.setPrintItemsTotalChf(totals.printItemsTotalChf()); + dto.setSetupCostChf(totals.setupCostChf()); + dto.setShippingCostChf(totals.shippingCostChf()); + dto.setGrandTotalChf(totals.grandTotalChf()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setCheckoutPath("/checkout/cad?session=" + session.getId()); + dto.setNotes(session.getNotes()); + dto.setCreatedAt(session.getCreatedAt()); + + if (session.getConvertedOrderId() != null) { + Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null); + dto.setConvertedOrderStatus(order != null ? order.getStatus() : null); + } return dto; } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 0fa09b3..1deb013 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -214,6 +214,11 @@ public class AdminOrderController { dto.setShippingCostChf(order.getShippingCostChf()); dto.setDiscountChf(order.getDiscountChf()); dto.setSubtotalChf(order.getSubtotalChf()); + dto.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); dto.setTotalChf(order.getTotalChf()); dto.setCreatedAt(order.getCreatedAt()); dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); diff --git a/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceCreateRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceCreateRequest.java new file mode 100644 index 0000000..987846c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceCreateRequest.java @@ -0,0 +1,52 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public class AdminCadInvoiceCreateRequest { + private UUID sessionId; + private UUID sourceRequestId; + private BigDecimal cadHours; + private BigDecimal cadHourlyRateChf; + private String notes; + + public UUID getSessionId() { + return sessionId; + } + + public void setSessionId(UUID sessionId) { + this.sessionId = sessionId; + } + + public UUID getSourceRequestId() { + return sourceRequestId; + } + + public void setSourceRequestId(UUID sourceRequestId) { + this.sourceRequestId = sourceRequestId; + } + + public BigDecimal getCadHours() { + return cadHours; + } + + public void setCadHours(BigDecimal cadHours) { + this.cadHours = cadHours; + } + + public BigDecimal getCadHourlyRateChf() { + return cadHourlyRateChf; + } + + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { + this.cadHourlyRateChf = cadHourlyRateChf; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceDto.java b/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceDto.java new file mode 100644 index 0000000..67449a5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminCadInvoiceDto.java @@ -0,0 +1,143 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminCadInvoiceDto { + private UUID sessionId; + private String sessionStatus; + private UUID sourceRequestId; + private BigDecimal cadHours; + private BigDecimal cadHourlyRateChf; + private BigDecimal cadTotalChf; + private BigDecimal printItemsTotalChf; + private BigDecimal setupCostChf; + private BigDecimal shippingCostChf; + private BigDecimal grandTotalChf; + private UUID convertedOrderId; + private String convertedOrderStatus; + private String checkoutPath; + private String notes; + private OffsetDateTime createdAt; + + public UUID getSessionId() { + return sessionId; + } + + public void setSessionId(UUID sessionId) { + this.sessionId = sessionId; + } + + public String getSessionStatus() { + return sessionStatus; + } + + public void setSessionStatus(String sessionStatus) { + this.sessionStatus = sessionStatus; + } + + public UUID getSourceRequestId() { + return sourceRequestId; + } + + public void setSourceRequestId(UUID sourceRequestId) { + this.sourceRequestId = sourceRequestId; + } + + public BigDecimal getCadHours() { + return cadHours; + } + + public void setCadHours(BigDecimal cadHours) { + this.cadHours = cadHours; + } + + public BigDecimal getCadHourlyRateChf() { + return cadHourlyRateChf; + } + + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { + this.cadHourlyRateChf = cadHourlyRateChf; + } + + public BigDecimal getCadTotalChf() { + return cadTotalChf; + } + + public void setCadTotalChf(BigDecimal cadTotalChf) { + this.cadTotalChf = cadTotalChf; + } + + public BigDecimal getPrintItemsTotalChf() { + return printItemsTotalChf; + } + + public void setPrintItemsTotalChf(BigDecimal printItemsTotalChf) { + this.printItemsTotalChf = printItemsTotalChf; + } + + 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 getGrandTotalChf() { + return grandTotalChf; + } + + public void setGrandTotalChf(BigDecimal grandTotalChf) { + this.grandTotalChf = grandTotalChf; + } + + public UUID getConvertedOrderId() { + return convertedOrderId; + } + + public void setConvertedOrderId(UUID convertedOrderId) { + this.convertedOrderId = convertedOrderId; + } + + public String getConvertedOrderStatus() { + return convertedOrderStatus; + } + + public void setConvertedOrderStatus(String convertedOrderStatus) { + this.convertedOrderStatus = convertedOrderStatus; + } + + public String getCheckoutPath() { + return checkoutPath; + } + + public void setCheckoutPath(String checkoutPath) { + this.checkoutPath = checkoutPath; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java index 47b0be5..1b362eb 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.time.OffsetDateTime; +import java.math.BigDecimal; import java.util.UUID; public class AdminQuoteSessionDto { @@ -10,6 +11,10 @@ public class AdminQuoteSessionDto { private OffsetDateTime createdAt; private OffsetDateTime expiresAt; private UUID convertedOrderId; + private UUID sourceRequestId; + private BigDecimal cadHours; + private BigDecimal cadHourlyRateChf; + private BigDecimal cadTotalChf; public UUID getId() { return id; @@ -58,4 +63,36 @@ public class AdminQuoteSessionDto { public void setConvertedOrderId(UUID convertedOrderId) { this.convertedOrderId = convertedOrderId; } + + public UUID getSourceRequestId() { + return sourceRequestId; + } + + public void setSourceRequestId(UUID sourceRequestId) { + this.sourceRequestId = sourceRequestId; + } + + public BigDecimal getCadHours() { + return cadHours; + } + + public void setCadHours(BigDecimal cadHours) { + this.cadHours = cadHours; + } + + public BigDecimal getCadHourlyRateChf() { + return cadHourlyRateChf; + } + + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { + this.cadHourlyRateChf = cadHourlyRateChf; + } + + public BigDecimal getCadTotalChf() { + return cadTotalChf; + } + + public void setCadTotalChf(BigDecimal cadTotalChf) { + this.cadTotalChf = cadTotalChf; + } } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java index 63e9f16..9653d99 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -23,6 +23,11 @@ public class OrderDto { private BigDecimal shippingCostChf; private BigDecimal discountChf; private BigDecimal subtotalChf; + private Boolean isCadOrder; + private UUID sourceRequestId; + private BigDecimal cadHours; + private BigDecimal cadHourlyRateChf; + private BigDecimal cadTotalChf; private BigDecimal totalChf; private OffsetDateTime createdAt; private String printMaterialCode; @@ -85,6 +90,21 @@ public class OrderDto { public BigDecimal getSubtotalChf() { return subtotalChf; } public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; } + public Boolean getIsCadOrder() { return isCadOrder; } + public void setIsCadOrder(Boolean isCadOrder) { this.isCadOrder = isCadOrder; } + + public UUID getSourceRequestId() { return sourceRequestId; } + public void setSourceRequestId(UUID sourceRequestId) { this.sourceRequestId = sourceRequestId; } + + public BigDecimal getCadHours() { return cadHours; } + public void setCadHours(BigDecimal cadHours) { this.cadHours = cadHours; } + + public BigDecimal getCadHourlyRateChf() { return cadHourlyRateChf; } + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { this.cadHourlyRateChf = cadHourlyRateChf; } + + public BigDecimal getCadTotalChf() { return cadTotalChf; } + public void setCadTotalChf(BigDecimal cadTotalChf) { this.cadTotalChf = cadTotalChf; } + public BigDecimal getTotalChf() { return totalChf; } public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; } diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java index da1feb1..1b01df1 100644 --- a/backend/src/main/java/com/printcalculator/entity/Order.java +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -119,6 +119,23 @@ public class Order { @Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2) private BigDecimal subtotalChf; + @ColumnDefault("false") + @Column(name = "is_cad_order", nullable = false) + private Boolean isCadOrder; + + @Column(name = "source_request_id") + private UUID sourceRequestId; + + @Column(name = "cad_hours", precision = 10, scale = 2) + private BigDecimal cadHours; + + @Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2) + private BigDecimal cadHourlyRateChf; + + @ColumnDefault("0.00") + @Column(name = "cad_total_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal cadTotalChf; + @ColumnDefault("0.00") @Column(name = "total_chf", nullable = false, precision = 12, scale = 2) private BigDecimal totalChf; @@ -400,6 +417,46 @@ public class Order { this.subtotalChf = subtotalChf; } + public Boolean getIsCadOrder() { + return isCadOrder; + } + + public void setIsCadOrder(Boolean isCadOrder) { + this.isCadOrder = isCadOrder; + } + + public UUID getSourceRequestId() { + return sourceRequestId; + } + + public void setSourceRequestId(UUID sourceRequestId) { + this.sourceRequestId = sourceRequestId; + } + + public BigDecimal getCadHours() { + return cadHours; + } + + public void setCadHours(BigDecimal cadHours) { + this.cadHours = cadHours; + } + + public BigDecimal getCadHourlyRateChf() { + return cadHourlyRateChf; + } + + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { + this.cadHourlyRateChf = cadHourlyRateChf; + } + + public BigDecimal getCadTotalChf() { + return cadTotalChf; + } + + public void setCadTotalChf(BigDecimal cadTotalChf) { + this.cadTotalChf = cadTotalChf; + } + public BigDecimal getTotalChf() { return totalChf; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java index 3979b54..e9746ef 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java @@ -61,6 +61,15 @@ public class QuoteSession { @Column(name = "converted_order_id") private UUID convertedOrderId; + @Column(name = "source_request_id") + private UUID sourceRequestId; + + @Column(name = "cad_hours", precision = 10, scale = 2) + private BigDecimal cadHours; + + @Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2) + private BigDecimal cadHourlyRateChf; + public UUID getId() { return id; } @@ -173,4 +182,28 @@ public class QuoteSession { this.convertedOrderId = convertedOrderId; } -} \ No newline at end of file + public UUID getSourceRequestId() { + return sourceRequestId; + } + + public void setSourceRequestId(UUID sourceRequestId) { + this.sourceRequestId = sourceRequestId; + } + + public BigDecimal getCadHours() { + return cadHours; + } + + public void setCadHours(BigDecimal cadHours) { + this.cadHours = cadHours; + } + + public BigDecimal getCadHourlyRateChf() { + return cadHourlyRateChf; + } + + public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { + this.cadHourlyRateChf = cadHourlyRateChf; + } + +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java index 51f4640..3811d32 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -8,4 +8,6 @@ import java.util.UUID; public interface QuoteSessionRepository extends JpaRepository { List findByCreatedAtBefore(java.time.OffsetDateTime cutoff); -} \ No newline at end of file + + List findByStatusInOrderByCreatedAtDesc(List statuses); +} diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java index 04da768..97840bf 100644 --- a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -14,6 +14,8 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.math.BigDecimal; +import java.math.RoundingMode; import com.printcalculator.entity.Order; import com.printcalculator.entity.OrderItem; @@ -95,6 +97,17 @@ public class InvoicePdfRenderingService { return line; }).collect(Collectors.toList()); + if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) { + BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO; + BigDecimal cadHourlyRate = order.getCadHourlyRateChf() != null ? order.getCadHourlyRateChf() : BigDecimal.ZERO; + Map cadLine = new HashMap<>(); + cadLine.put("description", "Servizio CAD (" + formatCadHours(cadHours) + "h)"); + cadLine.put("quantity", 1); + cadLine.put("unitPriceFormatted", String.format("CHF %.2f", cadHourlyRate)); + cadLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getCadTotalChf())); + invoiceLineItems.add(cadLine); + } + Map setupLine = new HashMap<>(); setupLine.put("description", "Costo Setup"); setupLine.put("quantity", 1); @@ -140,4 +153,8 @@ public class InvoicePdfRenderingService { return generateInvoicePdfBytesFromTemplate(vars, qrBillSvg); } + + private String formatCadHours(BigDecimal hours) { + return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); + } } diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 6ed8884..3a1f606 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -7,7 +7,6 @@ import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.repository.PricingPolicyRepository; import com.printcalculator.event.OrderCreatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -35,8 +34,7 @@ public class OrderService { private final QrBillService qrBillService; private final ApplicationEventPublisher eventPublisher; private final PaymentService paymentService; - private final QuoteCalculator quoteCalculator; - private final PricingPolicyRepository pricingRepo; + private final QuoteSessionTotalsService quoteSessionTotalsService; public OrderService(OrderRepository orderRepo, OrderItemRepository orderItemRepo, @@ -48,8 +46,7 @@ public class OrderService { QrBillService qrBillService, ApplicationEventPublisher eventPublisher, PaymentService paymentService, - QuoteCalculator quoteCalculator, - PricingPolicyRepository pricingRepo) { + QuoteSessionTotalsService quoteSessionTotalsService) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; @@ -60,8 +57,7 @@ public class OrderService { this.qrBillService = qrBillService; this.eventPublisher = eventPublisher; this.paymentService = paymentService; - this.quoteCalculator = quoteCalculator; - this.pricingRepo = pricingRepo; + this.quoteSessionTotalsService = quoteSessionTotalsService; } @Transactional @@ -148,60 +144,31 @@ public class OrderService { } List quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, quoteItems); + BigDecimal cadTotal = totals.cadTotalChf(); 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); - - // Calculate shipping cost based on dimensions before initial save - boolean exceedsBaseSize = false; - for (QuoteLineItem item : quoteItems) { - BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; - BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; - BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; - - BigDecimal[] dims = {x, y, z}; - java.util.Arrays.sort(dims); - - if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || - dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || - dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { - exceedsBaseSize = true; - break; - } - } - int totalQuantity = quoteItems.stream() - .mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1) - .sum(); - - if (exceedsBaseSize) { - order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00)); - } else { - order.setShippingCostChf(BigDecimal.valueOf(2.00)); - } + order.setShippingCostChf(totals.shippingCostChf()); + order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus())); + order.setSourceRequestId(session.getSourceRequestId()); + order.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); + order.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); + order.setCadTotalChf(cadTotal); order = orderRepo.save(order); List savedItems = new ArrayList<>(); - // Calculate global machine cost upfront - BigDecimal totalSeconds = BigDecimal.ZERO; - for (QuoteLineItem qItem : quoteItems) { - if (qItem.getPrintTimeSeconds() != null) { - totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity()))); - } - } - BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); - BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); - for (QuoteLineItem qItem : quoteItems) { OrderItem oItem = new OrderItem(); oItem.setOrder(order); oItem.setOriginalFilename(qItem.getOriginalFilename()); - oItem.setQuantity(qItem.getQuantity()); + int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1; + oItem.setQuantity(quantity); oItem.setColorCode(qItem.getColorCode()); oItem.setFilamentVariant(qItem.getFilamentVariant()); if (qItem.getFilamentVariant() != null @@ -212,17 +179,17 @@ public class OrderService { oItem.setMaterialCode(session.getMaterialCode()); } - BigDecimal distributedUnitPrice = qItem.getUnitPriceChf(); - if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { - BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())); - BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); - BigDecimal itemMachineCost = globalMachineCost.multiply(share); - BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP); + BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO; + if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity)); + BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP); distributedUnitPrice = distributedUnitPrice.add(unitMachineCost); } oItem.setUnitPriceChf(distributedUnitPrice); - oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity()))); + oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(quantity))); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setMaterialGrams(qItem.getMaterialGrams()); oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm()); @@ -260,9 +227,12 @@ public class OrderService { subtotal = subtotal.add(oItem.getLineTotalChf()); } - order.setSubtotalChf(subtotal); + order.setSubtotalChf(subtotal.add(cadTotal)); - BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); + BigDecimal total = order.getSubtotalChf() + .add(order.getSetupCostChf()) + .add(order.getShippingCostChf()) + .subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); order.setTotalChf(total); session.setConvertedOrderId(order.getId()); diff --git a/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java new file mode 100644 index 0000000..2d1ae10 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java @@ -0,0 +1,124 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.PricingPolicy; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.PricingPolicyRepository; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.List; + +@Service +public class QuoteSessionTotalsService { + private final PricingPolicyRepository pricingRepo; + private final QuoteCalculator quoteCalculator; + + public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, QuoteCalculator quoteCalculator) { + this.pricingRepo = pricingRepo; + this.quoteCalculator = quoteCalculator; + } + + public QuoteSessionTotals compute(QuoteSession session, List items) { + BigDecimal printItemsBaseTotal = BigDecimal.ZERO; + BigDecimal totalSeconds = BigDecimal.ZERO; + + for (QuoteLineItem item : items) { + int quantity = normalizeQuantity(item.getQuantity()); + BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO; + printItemsBaseTotal = printItemsBaseTotal.add(unitPrice.multiply(BigDecimal.valueOf(quantity))); + + if (item.getPrintTimeSeconds() != null && item.getPrintTimeSeconds() > 0) { + totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity))); + } + } + + BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); + BigDecimal printItemsTotal = printItemsBaseTotal.add(globalMachineCost); + + BigDecimal cadTotal = calculateCadTotal(session); + BigDecimal itemsTotal = printItemsTotal.add(cadTotal); + + BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + BigDecimal shippingCost = calculateShippingCost(items); + BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost); + + return new QuoteSessionTotals( + printItemsTotal, + globalMachineCost, + cadTotal, + itemsTotal, + setupFee, + shippingCost, + grandTotal, + totalSeconds + ); + } + + public BigDecimal calculateCadTotal(QuoteSession session) { + if (session == null) { + return BigDecimal.ZERO; + } + BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; + BigDecimal cadRate = session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO; + if (cadHours.compareTo(BigDecimal.ZERO) <= 0 || cadRate.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + return cadHours.multiply(cadRate).setScale(2, RoundingMode.HALF_UP); + } + + public BigDecimal calculateShippingCost(List items) { + if (items == null || items.isEmpty()) { + return BigDecimal.ZERO; + } + + boolean exceedsBaseSize = false; + for (QuoteLineItem item : items) { + BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; + BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; + BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; + + BigDecimal[] dims = {x, y, z}; + Arrays.sort(dims); + + if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 + || dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 + || dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { + exceedsBaseSize = true; + break; + } + } + + int totalQuantity = items.stream().mapToInt(i -> normalizeQuantity(i.getQuantity())).sum(); + if (totalQuantity <= 0) { + return BigDecimal.ZERO; + } + + if (exceedsBaseSize) { + return totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00); + } + return BigDecimal.valueOf(2.00); + } + + private int normalizeQuantity(Integer quantity) { + if (quantity == null || quantity < 1) { + return 1; + } + return quantity; + } + + public record QuoteSessionTotals( + BigDecimal printItemsTotalChf, + BigDecimal globalMachineCostChf, + BigDecimal cadTotalChf, + BigDecimal itemsTotalChf, + BigDecimal setupCostChf, + BigDecimal shippingCostChf, + BigDecimal grandTotalChf, + BigDecimal totalPrintSeconds + ) {} +} diff --git a/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java index ef333fe..b8c7137 100644 --- a/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java +++ b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java @@ -45,9 +45,8 @@ public class SessionCleanupService { // "rimangono in memoria... cancella quelle vecchie di 7 giorni". // Implementation plan said: status != 'ORDERED'. - // User specified statuses: ACTIVE, EXPIRED, CONVERTED. - // We should NOT delete sessions that have been converted to an order. - if ("CONVERTED".equals(session.getStatus())) { + // CAD_ACTIVE sessions are managed manually from back-office and must be preserved. + if ("CONVERTED".equals(session.getStatus()) || "CAD_ACTIVE".equals(session.getStatus())) { continue; } diff --git a/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java new file mode 100644 index 0000000..861b5f6 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java @@ -0,0 +1,84 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.PricingPolicy; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.PricingPolicyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class QuoteSessionTotalsServiceTest { + private PricingPolicyRepository pricingRepo; + private QuoteCalculator quoteCalculator; + private QuoteSessionTotalsService service; + + @BeforeEach + void setUp() { + pricingRepo = mock(PricingPolicyRepository.class); + quoteCalculator = mock(QuoteCalculator.class); + service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator); + } + + @Test + void compute_WithCadOnlySession_ShouldIncludeCadAndNoShipping() { + QuoteSession session = new QuoteSession(); + session.setSetupCostChf(BigDecimal.ZERO); + session.setCadHours(BigDecimal.valueOf(2)); + session.setCadHourlyRateChf(BigDecimal.valueOf(75)); + + PricingPolicy policy = new PricingPolicy(); + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteCalculator.calculateSessionMachineCost(eq(policy), any(BigDecimal.class))).thenReturn(BigDecimal.ZERO); + + QuoteSessionTotalsService.QuoteSessionTotals totals = service.compute(session, List.of()); + + assertAmountEquals("150.00", totals.cadTotalChf()); + assertAmountEquals("0.00", totals.shippingCostChf()); + assertAmountEquals("150.00", totals.itemsTotalChf()); + assertAmountEquals("150.00", totals.grandTotalChf()); + } + + @Test + void compute_WithPrintItemAndCad_ShouldSumEverything() { + QuoteSession session = new QuoteSession(); + session.setSetupCostChf(new BigDecimal("5.00")); + session.setCadHours(new BigDecimal("1.50")); + session.setCadHourlyRateChf(new BigDecimal("60.00")); + + QuoteLineItem item = new QuoteLineItem(); + item.setQuantity(2); + item.setUnitPriceChf(new BigDecimal("10.00")); + item.setPrintTimeSeconds(3600); + item.setBoundingBoxXMm(new BigDecimal("10")); + item.setBoundingBoxYMm(new BigDecimal("10")); + item.setBoundingBoxZMm(new BigDecimal("10")); + + PricingPolicy policy = new PricingPolicy(); + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteCalculator.calculateSessionMachineCost(policy, new BigDecimal("2.0000"))) + .thenReturn(new BigDecimal("3.00")); + + QuoteSessionTotalsService.QuoteSessionTotals totals = service.compute(session, List.of(item)); + + assertAmountEquals("23.00", totals.printItemsTotalChf()); + assertAmountEquals("90.00", totals.cadTotalChf()); + assertAmountEquals("113.00", totals.itemsTotalChf()); + assertAmountEquals("2.00", totals.shippingCostChf()); + assertAmountEquals("120.00", totals.grandTotalChf()); + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/db.sql b/db.sql index 8b1fe79..ce3a171 100644 --- a/db.sql +++ b/db.sql @@ -599,7 +599,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS ux_customers_email CREATE TABLE IF NOT EXISTS quote_sessions ( quote_session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - status text NOT NULL CHECK (status IN ('ACTIVE', 'EXPIRED', 'CONVERTED')), + status text NOT NULL CHECK (status IN ('ACTIVE', 'CAD_ACTIVE', 'EXPIRED', 'CONVERTED')), pricing_version text NOT NULL, -- Parametri "globali" (dalla tua UI avanzata) @@ -612,6 +612,9 @@ CREATE TABLE IF NOT EXISTS quote_sessions notes text, setup_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + source_request_id uuid, + cad_hours numeric(10, 2), + cad_hourly_rate_chf numeric(10, 2), created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, @@ -624,6 +627,25 @@ CREATE INDEX IF NOT EXISTS ix_quote_sessions_status CREATE INDEX IF NOT EXISTS ix_quote_sessions_expires_at ON quote_sessions (expires_at); +CREATE INDEX IF NOT EXISTS ix_quote_sessions_source_request + ON quote_sessions (source_request_id); + +ALTER TABLE quote_sessions + ADD COLUMN IF NOT EXISTS source_request_id uuid; + +ALTER TABLE quote_sessions + ADD COLUMN IF NOT EXISTS cad_hours numeric(10, 2); + +ALTER TABLE quote_sessions + ADD COLUMN IF NOT EXISTS cad_hourly_rate_chf numeric(10, 2); + +ALTER TABLE quote_sessions + DROP CONSTRAINT IF EXISTS quote_sessions_status_check; + +ALTER TABLE quote_sessions + ADD CONSTRAINT quote_sessions_status_check + CHECK (status IN ('ACTIVE', 'CAD_ACTIVE', 'EXPIRED', 'CONVERTED')); + -- ========================= -- QUOTE LINE ITEMS (1 file = 1 riga) -- ========================= @@ -676,6 +698,7 @@ CREATE TABLE IF NOT EXISTS orders ( order_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), source_quote_session_id uuid REFERENCES quote_sessions (quote_session_id), + source_request_id uuid, status text NOT NULL CHECK (status IN ( 'PENDING_PAYMENT', 'PAID', 'IN_PRODUCTION', @@ -717,6 +740,10 @@ CREATE TABLE IF NOT EXISTS orders discount_chf numeric(12, 2) NOT NULL DEFAULT 0.00, subtotal_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + is_cad_order boolean NOT NULL DEFAULT false, + cad_hours numeric(10, 2), + cad_hourly_rate_chf numeric(10, 2), + cad_total_chf numeric(12, 2) NOT NULL DEFAULT 0.00, total_chf numeric(12, 2) NOT NULL DEFAULT 0.00, created_at timestamptz NOT NULL DEFAULT now(), @@ -730,6 +757,24 @@ CREATE INDEX IF NOT EXISTS ix_orders_status CREATE INDEX IF NOT EXISTS ix_orders_customer_email ON orders (lower(customer_email)); +CREATE INDEX IF NOT EXISTS ix_orders_source_request + ON orders (source_request_id); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS source_request_id uuid; + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS is_cad_order boolean NOT NULL DEFAULT false; + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS cad_hours numeric(10, 2); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS cad_hourly_rate_chf numeric(10, 2); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS cad_total_chf numeric(12, 2) NOT NULL DEFAULT 0.00; + -- ========================= -- ORDER ITEMS (1 file 3D = 1 riga, file salvato su disco) -- ========================= @@ -849,3 +894,17 @@ CREATE TABLE IF NOT EXISTS custom_quote_request_attachments CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request ON custom_quote_request_attachments (request_id); + +ALTER TABLE quote_sessions + DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; + +ALTER TABLE quote_sessions + ADD CONSTRAINT fk_quote_sessions_source_request + FOREIGN KEY (source_request_id) REFERENCES custom_quote_requests (request_id); + +ALTER TABLE orders + DROP CONSTRAINT IF EXISTS fk_orders_source_request; + +ALTER TABLE orders + ADD CONSTRAINT fk_orders_source_request + FOREIGN KEY (source_request_id) REFERENCES custom_quote_requests (request_id); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 20652f6..2b5024f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -28,6 +28,13 @@ const appChildRoutes: Routes = [ loadChildren: () => import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), }, + { + path: 'checkout/cad', + loadComponent: () => + import('./features/checkout/checkout.component').then( + (m) => m.CheckoutComponent, + ), + }, { path: 'checkout', loadComponent: () => diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 201febd..6703ac7 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -53,7 +53,7 @@ } -
+
m.AdminSessionsComponent, ), }, + { + path: 'cad-invoices', + loadComponent: () => + import('./pages/admin-cad-invoices.component').then( + (m) => m.AdminCadInvoicesComponent, + ), + }, ], }, ]; diff --git a/frontend/src/app/features/admin/pages/admin-cad-invoices.component.html b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.html new file mode 100644 index 0000000..d8aed21 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.html @@ -0,0 +1,128 @@ +
+
+

Fatture CAD

+

+ Crea un checkout CAD partendo da una sessione esistente (opzionale) e + gestisci lo stato fino all'ordine. +

+
+ + + +

{{ errorMessage }}

+

{{ successMessage }}

+ +
+

Crea nuova fattura CAD

+
+ + + + + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SessioneRichiestaOre CADTariffaTotale CADTotale ordineStato sessioneNotaOrdineAzioni
{{ row.sessionId | slice: 0 : 8 }}{{ row.sourceRequestId || "-" }}{{ row.cadHours }}{{ row.cadHourlyRateChf | currency: "CHF" }}{{ row.cadTotalChf | currency: "CHF" }}{{ row.grandTotalChf | currency: "CHF" }}{{ row.sessionStatus }}{{ row.notes || "-" }} + + {{ row.convertedOrderId | slice: 0 : 8 }} ({{ + row.convertedOrderStatus || "-" + }}) + + - + + + + +
Nessuna fattura CAD trovata.
+
+ + + +

Caricamento fatture CAD...

+
diff --git a/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss new file mode 100644 index 0000000..abc3368 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss @@ -0,0 +1,140 @@ +.cad-page { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.page-header { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: flex-start; +} + +.page-header h1 { + margin: 0; +} + +.page-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4); + background: var(--color-brand); + color: var(--color-neutral-900); + font-weight: 600; + cursor: pointer; +} + +button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.create-box { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-4); +} + +.create-box h2 { + margin-top: 0; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.form-grid span { + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.form-grid input { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2); +} + +.notes-field { + grid-column: 1 / -1; +} + +.form-grid textarea { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2); + resize: vertical; +} + +.create-actions { + margin-top: var(--space-3); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 1100px; +} + +th, +td { + border-bottom: 1px solid var(--color-border); + padding: var(--space-2); + text-align: left; +} + +.notes-cell { + max-width: 280px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.ghost { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.error { + color: var(--color-danger-500); +} + +.success { + color: var(--color-success-500); +} + +@media (max-width: 880px) { + .page-header { + flex-direction: column; + align-items: stretch; + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-cad-invoices.component.ts b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.ts new file mode 100644 index 0000000..0de7f4e --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.ts @@ -0,0 +1,157 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + AdminCadInvoice, + AdminOperationsService, +} from '../services/admin-operations.service'; +import { AdminOrdersService } from '../services/admin-orders.service'; + +@Component({ + selector: 'app-admin-cad-invoices', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-cad-invoices.component.html', + styleUrl: './admin-cad-invoices.component.scss', +}) +export class AdminCadInvoicesComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + private readonly adminOrdersService = inject(AdminOrdersService); + + invoices: AdminCadInvoice[] = []; + loading = false; + creating = false; + errorMessage: string | null = null; + successMessage: string | null = null; + + form = { + sessionId: '', + sourceRequestId: '', + cadHours: 1, + cadHourlyRateChf: '', + notes: '', + }; + + ngOnInit(): void { + this.loadCadInvoices(); + } + + loadCadInvoices(): void { + this.loading = true; + this.errorMessage = null; + this.adminOperationsService.listCadInvoices().subscribe({ + next: (rows) => { + this.invoices = rows; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare le fatture CAD.'; + }, + }); + } + + createCadInvoice(): void { + if (this.creating) { + return; + } + + const cadHours = Number(this.form.cadHours); + if (!Number.isFinite(cadHours) || cadHours <= 0) { + this.errorMessage = 'Inserisci ore CAD valide (> 0).'; + return; + } + + this.creating = true; + this.errorMessage = null; + this.successMessage = null; + + let payload: { + sessionId?: string; + sourceRequestId?: string; + cadHours: number; + cadHourlyRateChf?: number; + notes?: string; + }; + + try { + const sessionIdRaw = String(this.form.sessionId ?? '').trim(); + const sourceRequestIdRaw = String(this.form.sourceRequestId ?? '').trim(); + const cadRateRaw = String(this.form.cadHourlyRateChf ?? '').trim(); + const notesRaw = String(this.form.notes ?? '').trim(); + + payload = { + sessionId: sessionIdRaw || undefined, + sourceRequestId: sourceRequestIdRaw || undefined, + cadHours, + cadHourlyRateChf: + cadRateRaw.length > 0 && Number.isFinite(Number(cadRateRaw)) + ? Number(cadRateRaw) + : undefined, + notes: notesRaw.length > 0 ? notesRaw : undefined, + }; + } catch { + this.creating = false; + this.errorMessage = 'Valori form non validi.'; + return; + } + + this.adminOperationsService.createCadInvoice(payload).subscribe({ + next: (created) => { + this.creating = false; + this.successMessage = `Fattura CAD pronta. Sessione: ${created.sessionId}`; + this.loadCadInvoices(); + }, + error: (err) => { + this.creating = false; + this.errorMessage = + err?.error?.message || 'Creazione fattura CAD non riuscita.'; + }, + }); + } + + openCheckout(path: string): void { + const url = this.toCheckoutUrl(path); + window.open(url, '_blank'); + } + + copyCheckout(path: string): void { + const url = this.toCheckoutUrl(path); + navigator.clipboard?.writeText(url); + this.successMessage = 'Link checkout CAD copiato negli appunti.'; + } + + downloadInvoice(orderId?: string): void { + if (!orderId) return; + this.adminOrdersService.downloadOrderInvoice(orderId).subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fattura-cad-${orderId}.pdf`; + a.click(); + window.URL.revokeObjectURL(url); + }, + error: () => { + this.errorMessage = 'Download fattura non riuscito.'; + }, + }); + } + + private toCheckoutUrl(path: string): string { + const safePath = path.startsWith('/') ? path : `/${path}`; + const lang = this.resolveLang(); + return `${window.location.origin}/${lang}${safePath}`; + } + + private resolveLang(): string { + const firstSegment = window.location.pathname + .split('/') + .filter(Boolean) + .shift(); + if (firstSegment && ['it', 'en', 'de', 'fr'].includes(firstSegment)) { + return firstSegment; + } + return 'it'; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss index cfbf87a..825c455 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -199,18 +199,20 @@ tbody tr.selected { .request-id { margin: var(--space-2) 0 0; display: flex; - align-items: center; + align-items: flex-start; + flex-wrap: wrap; gap: 8px; font-size: 0.8rem; color: var(--color-text-muted); } .request-id code { - display: inline-block; - max-width: 260px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: block; + max-width: 100%; + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; color: var(--color-text); background: var(--color-neutral-100); border: 1px solid var(--color-border); diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html index 6c16d74..6d1f28a 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -47,6 +47,13 @@ > {{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }} + - -
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 43c65c7..e6ad060 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -98,9 +98,11 @@

Dettaglio ordine {{ selectedOrder.orderNumber }}

UUID: - {{ - selectedOrder.id - }} + {{ selectedOrder.id }}

Caricamento dettaglio...

diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html index 5b47816..35f5203 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.html +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -16,9 +16,7 @@ >Richieste contatto Sessioni - Fatture CAD + Fatture CAD
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 7049e66..6e56a60 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -277,7 +277,9 @@ export class CalculatorPageComponent implements OnInit { const segments = this.cadSessionLocked() ? ['/', this.languageService.selectedLang(), 'checkout', 'cad'] : ['/', this.languageService.selectedLang(), 'checkout']; - this.router.navigate(segments, { queryParams: { session: res.sessionId } }); + this.router.navigate(segments, { + queryParams: { session: res.sessionId }, + }); } else { console.error('No session ID found in quote result'); // Fallback or error handling @@ -356,7 +358,11 @@ export class CalculatorPageComponent implements OnInit { onConsult() { if (!this.currentRequest) { - this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); + this.router.navigate([ + '/', + this.languageService.selectedLang(), + 'contact', + ]); return; } -- 2.49.1 From 47e22c5a61d5ea12312fa99dda5c1f0df533c246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 4 Mar 2026 12:25:23 +0100 Subject: [PATCH 4/4] feat(back-end and front-end) email for request --- frontend/src/app/features/home/home.component.scss | 4 ++-- frontend/src/assets/i18n/de.json | 4 ++-- frontend/src/assets/i18n/en.json | 4 ++-- frontend/src/assets/i18n/fr.json | 4 ++-- frontend/src/assets/i18n/it.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 416e16a..28c3267 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -205,10 +205,10 @@ gap: var(--space-3); } -.capabilities { +.section.capabilities { position: relative; border-bottom: 1px solid var(--color-border); - padding-top: 3rem; + padding-top: 4.5rem; } .capabilities-bg { display: none; diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 8f2f246..a3b0cf5 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -472,8 +472,8 @@ "FOUNDER_IMAGE_ALT_1": "Gründer - Foto 1", "FOUNDER_IMAGE_ALT_2": "Gründer - Foto 2", "HERO_EYEBROW": "Technischer 3D-Druck für Unternehmen, Freelancer und Maker", - "HERO_TITLE": "Preis und Lieferzeit in wenigen Sekunden.
Von der 3D-Datei zum fertigen Teil.", - "HERO_LEAD": "Der fortschrittlichste Rechner für Ihre 3D-Drucke: maximale Präzision und keine Überraschungen.", + "HERO_TITLE": "3D-Druckservice.
Von der Datei zum fertigen Teil.", + "HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.", "HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!", "BTN_CALCULATE": "Angebot berechnen", "BTN_SHOP": "Zum Shop", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index bba2072..54cfde0 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -472,8 +472,8 @@ "FOUNDER_IMAGE_ALT_1": "Founder - photo 1", "FOUNDER_IMAGE_ALT_2": "Founder - photo 2", "HERO_EYEBROW": "Technical 3D printing for businesses, freelancers and makers", - "HERO_TITLE": "Price and lead time in a few seconds.
From 3D file to finished part.", - "HERO_LEAD": "The most advanced calculator for your 3D prints: total precision and zero surprises.", + "HERO_TITLE": "3D printing service.
From file to finished part.", + "HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.", "HERO_SUBTITLE": "We also offer CAD services for custom parts!", "BTN_CALCULATE": "Calculate Quote", "BTN_SHOP": "Go to shop", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 8b35f5b..58ff6de 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -14,8 +14,8 @@ }, "HOME": { "HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers", - "HERO_TITLE": "Prix et délais en quelques secondes.
Du fichier 3D à la pièce finie.", - "HERO_LEAD": "Le calculateur le plus avancé pour vos impressions 3D : précision totale et zéro surprise.", + "HERO_TITLE": "Service d'impression 3D.
Du fichier à la pièce finie.", + "HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.", "HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !", "BTN_CALCULATE": "Calculer un devis", "BTN_SHOP": "Aller à la boutique", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index d041a63..3ed21d0 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -14,8 +14,8 @@ }, "HOME": { "HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker", - "HERO_TITLE": "Prezzo e tempi in pochi secondi.
Dal file 3D al pezzo finito.", - "HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", + "HERO_TITLE": "Servizio di stampa 3D.
Dal file al pezzo finito.", + "HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", "HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!", "BTN_CALCULATE": "Calcola Preventivo", "BTN_SHOP": "Vai allo shop", -- 2.49.1