diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index b407bcb..003ade8 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -29,6 +29,8 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.time.OffsetDateTime; import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -53,6 +55,9 @@ public class CustomQuoteRequestController { @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") private String contactRequestAdminMailAddress; + @Value("${app.mail.contact-request.customer.enabled:true}") + private boolean contactRequestCustomerMailEnabled; + // TODO: Inject Storage Service private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); @@ -97,6 +102,7 @@ public class CustomQuoteRequestController { "Accettazione Termini e Privacy obbligatoria." ); } + String language = normalizeLanguage(requestDto.getLanguage()); // 1. Create Request CustomQuoteRequest request = new CustomQuoteRequest(); @@ -173,6 +179,7 @@ public class CustomQuoteRequestController { } sendAdminContactRequestNotification(request, attachmentsCount); + sendCustomerContactRequestConfirmation(request, attachmentsCount, language); return ResponseEntity.ok(request); } @@ -258,6 +265,252 @@ public class CustomQuoteRequestController { ); } + private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) { + if (!contactRequestCustomerMailEnabled) { + return; + } + if (request.getEmail() == null || request.getEmail().isBlank()) { + logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId()); + return; + } + + Map templateData = new HashMap<>(); + templateData.put("requestId", request.getId()); + templateData.put( + "createdAt", + request.getCreatedAt().format( + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language)) + ) + ); + templateData.put("recipientName", resolveRecipientName(request, language)); + templateData.put("requestType", localizeRequestType(request.getRequestType(), language)); + templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language)); + templateData.put("name", safeValue(request.getName())); + templateData.put("companyName", safeValue(request.getCompanyName())); + templateData.put("contactPerson", safeValue(request.getContactPerson())); + templateData.put("email", safeValue(request.getEmail())); + templateData.put("phone", safeValue(request.getPhone())); + templateData.put("message", safeValue(request.getMessage())); + templateData.put("attachmentsCount", attachmentsCount); + templateData.put("currentYear", Year.now().getValue()); + String subject = applyCustomerContactRequestTexts(templateData, language, request.getId()); + + emailNotificationService.sendEmail( + request.getEmail(), + subject, + "contact-request-customer", + templateData + ); + } + + private String applyCustomerContactRequestTexts( + Map templateData, + String language, + UUID requestId + ) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Contact request received"); + templateData.put("headlineText", "We received your contact request"); + templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ","); + templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible."); + templateData.put("requestIdHintText", "Please keep this request ID for future order references:"); + templateData.put("detailsTitleText", "Request details"); + templateData.put("labelRequestId", "Request ID"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Request type"); + templateData.put("labelCustomerType", "Customer type"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Company"); + templateData.put("labelContactPerson", "Contact person"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Phone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Attachments"); + templateData.put("supportText", "If you need help, reply to this email."); + templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab."); + yield "We received your contact request #" + requestId + " - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Kontaktanfrage erhalten"); + templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten"); + templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ","); + templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich."); + templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:"); + templateData.put("detailsTitleText", "Anfragedetails"); + templateData.put("labelRequestId", "Anfrage-ID"); + templateData.put("labelDate", "Datum"); + templateData.put("labelRequestType", "Anfragetyp"); + templateData.put("labelCustomerType", "Kundentyp"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Firma"); + templateData.put("labelContactPerson", "Kontaktperson"); + templateData.put("labelEmail", "E-Mail"); + templateData.put("labelPhone", "Telefon"); + templateData.put("labelMessage", "Nachricht"); + templateData.put("labelAttachments", "Anhaenge"); + templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail."); + templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab."); + yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Demande de contact recue"); + templateData.put("headlineText", "Nous avons recu votre demande de contact"); + templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ","); + templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible."); + templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :"); + templateData.put("detailsTitleText", "Details de la demande"); + templateData.put("labelRequestId", "ID de demande"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Type de demande"); + templateData.put("labelCustomerType", "Type de client"); + templateData.put("labelName", "Nom"); + templateData.put("labelCompany", "Entreprise"); + templateData.put("labelContactPerson", "Contact"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telephone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Pieces jointes"); + templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); + templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab."); + yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Richiesta di contatto ricevuta"); + templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto"); + templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ","); + templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile."); + templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:"); + templateData.put("detailsTitleText", "Dettagli richiesta"); + templateData.put("labelRequestId", "ID richiesta"); + templateData.put("labelDate", "Data"); + templateData.put("labelRequestType", "Tipo richiesta"); + templateData.put("labelCustomerType", "Tipo cliente"); + templateData.put("labelName", "Nome"); + templateData.put("labelCompany", "Azienda"); + templateData.put("labelContactPerson", "Contatto"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telefono"); + templateData.put("labelMessage", "Messaggio"); + templateData.put("labelAttachments", "Allegati"); + templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email."); + templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab."); + yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab"; + } + }; + } + + private String localizeRequestType(String requestType, String language) { + if (requestType == null || requestType.isBlank()) { + return "-"; + } + + String normalized = requestType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "custom", "print_service" -> "Custom part request"; + case "series" -> "Series production request"; + case "consult", "design_service" -> "Consultation request"; + case "question" -> "General question"; + default -> requestType; + }; + case "de" -> switch (normalized) { + case "custom", "print_service" -> "Anfrage fuer Einzelteil"; + case "series" -> "Anfrage fuer Serienproduktion"; + case "consult", "design_service" -> "Beratungsanfrage"; + case "question" -> "Allgemeine Frage"; + default -> requestType; + }; + case "fr" -> switch (normalized) { + case "custom", "print_service" -> "Demande de piece personnalisee"; + case "series" -> "Demande de production en serie"; + case "consult", "design_service" -> "Demande de conseil"; + case "question" -> "Question generale"; + default -> requestType; + }; + default -> switch (normalized) { + case "custom", "print_service" -> "Richiesta pezzo personalizzato"; + case "series" -> "Richiesta produzione in serie"; + case "consult", "design_service" -> "Richiesta consulenza"; + case "question" -> "Domanda generale"; + default -> requestType; + }; + }; + } + + private String localizeCustomerType(String customerType, String language) { + if (customerType == null || customerType.isBlank()) { + return "-"; + } + String normalized = customerType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "private" -> "Private"; + case "business" -> "Business"; + default -> customerType; + }; + case "de" -> switch (normalized) { + case "private" -> "Privat"; + case "business" -> "Unternehmen"; + default -> customerType; + }; + case "fr" -> switch (normalized) { + case "private" -> "Prive"; + case "business" -> "Entreprise"; + default -> customerType; + }; + default -> switch (normalized) { + case "private" -> "Privato"; + case "business" -> "Azienda"; + default -> customerType; + }; + }; + } + + private Locale localeForLanguage(String language) { + return switch (language) { + case "en" -> Locale.ENGLISH; + case "de" -> Locale.GERMAN; + case "fr" -> Locale.FRENCH; + default -> Locale.ITALIAN; + }; + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + String normalized = language.toLowerCase(Locale.ROOT).trim(); + if (normalized.startsWith("en")) { + return "en"; + } + if (normalized.startsWith("de")) { + return "de"; + } + if (normalized.startsWith("fr")) { + return "fr"; + } + return "it"; + } + + private String resolveRecipientName(CustomQuoteRequest request, String language) { + if (request.getName() != null && !request.getName().isBlank()) { + return request.getName().trim(); + } + if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { + return request.getContactPerson().trim(); + } + if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) { + return request.getCompanyName().trim(); + } + return switch (language) { + case "en" -> "customer"; + case "de" -> "Kunde"; + case "fr" -> "client"; + default -> "cliente"; + }; + } + private String safeValue(String value) { if (value == null || value.isBlank()) { return "-"; 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/dto/QuoteRequestDto.java b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java index 70d36ba..afa8f1c 100644 --- a/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java +++ b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.AssertTrue; public class QuoteRequestDto { private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" private String customerType; // "PRIVATE" or "BUSINESS" + private String language; // "it" | "en" | "de" | "fr" private String email; private String phone; private String name; 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/main/resources/application.properties b/backend/src/main/resources/application.properties index ad6e2a0..3769350 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -44,6 +44,7 @@ app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true} app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch} +app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} # Admin back-office authentication diff --git a/backend/src/main/resources/templates/email/contact-request-customer.html b/backend/src/main/resources/templates/email/contact-request-customer.html new file mode 100644 index 0000000..d308b0c --- /dev/null +++ b/backend/src/main/resources/templates/email/contact-request-customer.html @@ -0,0 +1,134 @@ + + + + + Contact request received + + + +
+

We received your contact request

+

Hi customer,

+

Thank you for contacting us. Our team will reply as soon as possible.

+

+ Please keep this request ID for future order references: + 00000000-0000-0000-0000-000000000000 +

+

Request details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Request ID00000000-0000-0000-0000-000000000000
Date2026-03-03T10:00:00Z
Request typecustom
Customer typePRIVATE
NameMario Rossi
Company3D Fab SA
Contact personMario Rossi
Emailcliente@example.com
Phone+41 00 000 00 00
MessageTesto richiesta cliente...
Attachments0
+

If you need help, reply to this email.

+ + +
+ + 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..9dc0022 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.html @@ -0,0 +1,149 @@ +
+
+

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..07bccf0 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.ts @@ -0,0 +1,158 @@ +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'; +import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; + +@Component({ + selector: 'app-admin-cad-invoices', + standalone: true, + imports: [CommonModule, FormsModule, CopyOnClickDirective], + 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.html b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html index cd524eb..b5c596a 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html @@ -76,7 +76,12 @@

Dettaglio richiesta

- ID{{ selectedRequest.id }} + ID + {{ selectedRequest.id }}

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-contact-requests.component.ts b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts index c99e810..13a2dc5 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts @@ -7,11 +7,12 @@ import { AdminContactRequestDetail, AdminOperationsService, } from '../services/admin-operations.service'; +import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; @Component({ selector: 'app-admin-contact-requests', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, CopyOnClickDirective], templateUrl: './admin-contact-requests.component.html', styleUrl: './admin-contact-requests.component.scss', }) 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 ce669d2..e6ad060 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -97,7 +97,12 @@

Dettaglio ordine {{ selectedOrder.orderNumber }}

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

Caricamento dettaglio...

diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 336517d..8a3c23e 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -5,11 +5,12 @@ import { AdminOrder, AdminOrdersService, } from '../services/admin-orders.service'; +import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; @Component({ selector: 'app-admin-dashboard', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, CopyOnClickDirective], templateUrl: './admin-dashboard.component.html', styleUrl: './admin-dashboard.component.scss', }) 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..48492ef 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -33,12 +33,23 @@ - {{ session.id | slice: 0 : 8 }} + + {{ session.id | slice: 0 : 8 }} + {{ session.createdAt | date: "short" }} {{ session.expiresAt | date: "short" }} {{ session.materialCode }} {{ session.status }} - {{ session.convertedOrderId || "-" }} + + {{ + session.convertedOrderId + ? (session.convertedOrderId | slice: 0 : 8) + : "-" + }} +