feat/cad-bill #16

Merged
JoeKung merged 6 commits from feat/cad-bill into dev 2026-03-04 12:45:58 +01:00
43 changed files with 1594 additions and 150 deletions
Showing only changes of commit 1b3f0b16ff - Show all commits

View File

@@ -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());

View File

@@ -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<QuoteLineItem> 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<Map<String, Object>> 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<String, Object> 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);
}

View File

@@ -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<List<AdminCadInvoiceDto>> getCadInvoices() {
List<AdminCadInvoiceDto> 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<AdminCadInvoiceDto> 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<Void> 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<QuoteLineItem> 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;
}

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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;
}
}
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;
}
}

View File

@@ -8,4 +8,6 @@ import java.util.UUID;
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
}
List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses);
}

View File

@@ -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<String, Object> 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<String, Object> 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();
}
}

View File

@@ -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<QuoteLineItem> 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<OrderItem> 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());

View File

@@ -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<QuoteLineItem> 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<QuoteLineItem> 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
) {}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

61
db.sql
View File

@@ -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);

View File

@@ -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: () =>

View File

@@ -53,7 +53,7 @@
}
</select>
<div class="icon-placeholder">
<div class="icon-placeholder" routerLink="/admin">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"

View File

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { LanguageService } from '../services/language.service';
import {routes} from '../../app.routes';
@Component({
selector: 'app-navbar',
@@ -37,4 +38,6 @@ export class NavbarComponent {
closeMenu() {
this.isMenuOpen = false;
}
protected readonly routes = routes;
}

View File

@@ -50,6 +50,13 @@ export const ADMIN_ROUTES: Routes = [
(m) => m.AdminSessionsComponent,
),
},
{
path: 'cad-invoices',
loadComponent: () =>
import('./pages/admin-cad-invoices.component').then(
(m) => m.AdminCadInvoicesComponent,
),
},
],
},
];

View File

@@ -0,0 +1,128 @@
<section class="cad-page">
<header class="page-header">
<div>
<h1>Fatture CAD</h1>
<p>
Crea un checkout CAD partendo da una sessione esistente (opzionale) e
gestisci lo stato fino all'ordine.
</p>
</div>
<button type="button" (click)="loadCadInvoices()" [disabled]="loading">
Aggiorna
</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
<section class="create-box">
<h2>Crea nuova fattura CAD</h2>
<div class="form-grid">
<label>
<span>ID Sessione (opzionale)</span>
<input
[(ngModel)]="form.sessionId"
placeholder="UUID sessione quote"
type="text"
/>
</label>
<label>
<span>ID Richiesta Contatto (opzionale)</span>
<input
[(ngModel)]="form.sourceRequestId"
placeholder="UUID richiesta contatto"
type="text"
/>
</label>
<label>
<span>Ore CAD</span>
<input [(ngModel)]="form.cadHours" min="0.1" step="0.1" type="number" />
</label>
<label>
<span>Tariffa CAD CHF/h (opzionale)</span>
<input
[(ngModel)]="form.cadHourlyRateChf"
placeholder="Se vuoto usa pricing policy attiva"
min="0"
step="0.05"
type="number"
/>
</label>
<label class="notes-field">
<span>Nota (opzionale)</span>
<textarea
[(ngModel)]="form.notes"
placeholder="Nota visibile nel checkout CAD (es. dettagli lavorazione)"
rows="3"
></textarea>
</label>
</div>
<div class="create-actions">
<button type="button" (click)="createCadInvoice()" [disabled]="creating">
{{ creating ? "Creazione..." : "Crea link checkout CAD" }}
</button>
</div>
</section>
<section class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Sessione</th>
<th>Richiesta</th>
<th>Ore CAD</th>
<th>Tariffa</th>
<th>Totale CAD</th>
<th>Totale ordine</th>
<th>Stato sessione</th>
<th>Nota</th>
<th>Ordine</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of invoices">
<td [title]="row.sessionId">{{ row.sessionId | slice: 0 : 8 }}</td>
<td>{{ row.sourceRequestId || "-" }}</td>
<td>{{ row.cadHours }}</td>
<td>{{ row.cadHourlyRateChf | currency: "CHF" }}</td>
<td>{{ row.cadTotalChf | currency: "CHF" }}</td>
<td>{{ row.grandTotalChf | currency: "CHF" }}</td>
<td>{{ row.sessionStatus }}</td>
<td class="notes-cell" [title]="row.notes || ''">{{ row.notes || "-" }}</td>
<td>
<span *ngIf="row.convertedOrderId; else noOrder">
{{ row.convertedOrderId | slice: 0 : 8 }} ({{
row.convertedOrderStatus || "-"
}})
</span>
<ng-template #noOrder>-</ng-template>
</td>
<td class="actions">
<button type="button" class="ghost" (click)="openCheckout(row.checkoutPath)">
Apri checkout
</button>
<button type="button" class="ghost" (click)="copyCheckout(row.checkoutPath)">
Copia link
</button>
<button
type="button"
class="ghost"
*ngIf="row.convertedOrderId"
(click)="downloadInvoice(row.convertedOrderId)"
>
Scarica fattura
</button>
</td>
</tr>
<tr *ngIf="invoices.length === 0">
<td colspan="10">Nessuna fattura CAD trovata.</td>
</tr>
</tbody>
</table>
</section>
</section>
<ng-template #loadingTpl>
<p>Caricamento fatture CAD...</p>
</ng-template>

View File

@@ -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;
}
}

View File

@@ -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';
}
}

View File

@@ -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);

View File

@@ -47,6 +47,13 @@
>
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
</button>
<button
type="button"
class="btn-secondary"
(click)="copySessionUuid(session.id)"
>
Copia UUID
</button>
<button
type="button"
class="btn-danger"
@@ -78,6 +85,11 @@
"
class="detail-box"
>
<div class="detail-session-id">
<strong>UUID sessione:</strong>
<code>{{ detail.session.id }}</code>
</div>
<div class="detail-summary">
<div>
<strong>Elementi:</strong> {{ detail.items.length }}

View File

@@ -103,6 +103,22 @@ td {
padding: var(--space-4);
}
.detail-session-id {
margin-bottom: var(--space-3);
display: grid;
gap: 4px;
}
.detail-session-id code {
display: block;
max-width: 100%;
padding: var(--space-2);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-neutral-100);
overflow-wrap: anywhere;
}
.detail-summary {
display: flex;
flex-wrap: wrap;

View File

@@ -135,6 +135,42 @@ export class AdminSessionsComponent implements OnInit {
return `${hours}h ${minutes}m`;
}
copySessionUuid(sessionId: string): void {
if (!sessionId) {
return;
}
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(sessionId).then(
() => {
this.errorMessage = null;
this.successMessage = 'UUID sessione copiato.';
},
() => {
this.errorMessage = 'Impossibile copiare UUID sessione.';
},
);
return;
}
// Fallback for older browsers.
const textarea = document.createElement('textarea');
textarea.value = sessionId;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
this.errorMessage = null;
this.successMessage = 'UUID sessione copiato.';
} catch {
this.errorMessage = 'Impossibile copiare UUID sessione.';
} finally {
document.body.removeChild(textarea);
}
}
private extractErrorMessage(error: unknown, fallback: string): string {
const err = error as { error?: { message?: string } };
return err?.error?.message || fallback;

View File

@@ -16,6 +16,9 @@
>Richieste contatto</a
>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
<a routerLink="cad-invoices" routerLinkActive="active"
>Fatture CAD</a
>
</nav>
</div>

View File

@@ -115,6 +115,10 @@ export interface AdminQuoteSession {
createdAt: string;
expiresAt: string;
convertedOrderId?: string;
sourceRequestId?: string;
cadHours?: number;
cadHourlyRateChf?: number;
cadTotalChf?: number;
}
export interface AdminQuoteSessionDetailItem {
@@ -136,14 +140,45 @@ export interface AdminQuoteSessionDetail {
setupCostChf?: number;
supportsEnabled?: boolean;
notes?: string;
sourceRequestId?: string;
cadHours?: number;
cadHourlyRateChf?: number;
};
items: AdminQuoteSessionDetailItem[];
printItemsTotalChf: number;
cadTotalChf: number;
itemsTotalChf: number;
shippingCostChf: number;
globalMachineCostChf: number;
grandTotalChf: number;
}
export interface AdminCreateCadInvoicePayload {
sessionId?: string;
sourceRequestId?: string;
cadHours: number;
cadHourlyRateChf?: number;
notes?: string;
}
export interface AdminCadInvoice {
sessionId: string;
sessionStatus: string;
sourceRequestId?: string;
cadHours: number;
cadHourlyRateChf: number;
cadTotalChf: number;
printItemsTotalChf: number;
setupCostChf: number;
shippingCostChf: number;
grandTotalChf: number;
convertedOrderId?: string;
convertedOrderStatus?: string;
checkoutPath: string;
notes?: string;
createdAt: string;
}
@Injectable({
providedIn: 'root',
})
@@ -279,4 +314,20 @@ export class AdminOperationsService {
{ withCredentials: true },
);
}
listCadInvoices(): Observable<AdminCadInvoice[]> {
return this.http.get<AdminCadInvoice[]>(`${this.baseUrl}/cad-invoices`, {
withCredentials: true,
});
}
createCadInvoice(
payload: AdminCreateCadInvoicePayload,
): Observable<AdminCadInvoice> {
return this.http.post<AdminCadInvoice>(
`${this.baseUrl}/cad-invoices`,
payload,
{ withCredentials: true },
);
}
}

View File

@@ -24,6 +24,11 @@ export interface AdminOrder {
customerEmail: string;
totalChf: number;
createdAt: string;
isCadOrder?: boolean;
sourceRequestId?: string;
cadHours?: number;
cadHourlyRateChf?: number;
cadTotalChf?: number;
printMaterialCode?: string;
printNozzleDiameterMm?: number;
printLayerHeightMm?: number;

View File

@@ -23,14 +23,16 @@
<div
class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')"
[class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('easy')"
>
{{ "CALC.MODE_EASY" | translate }}
</div>
<div
class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"
[class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('advanced')"
>
{{ "CALC.MODE_ADVANCED" | translate }}
</div>
@@ -39,6 +41,7 @@
<app-upload-form
#uploadForm
[mode]="mode()"
[lockedSettings]="cadSessionLocked()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"

View File

@@ -80,6 +80,11 @@
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.benefits {

View File

@@ -48,6 +48,7 @@ export class CalculatorPageComponent implements OnInit {
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
cadSessionLocked = signal(false);
error = signal<boolean>(false);
errorKey = signal<string>('CALC.ERROR_GENERIC');
isZeroQuoteError = computed(
@@ -100,6 +101,8 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
@@ -206,6 +209,7 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(null);
this.cadSessionLocked.set(false);
this.orderSuccess.set(false);
// Auto-scroll on mobile to make analysis visible
@@ -270,10 +274,10 @@ export class CalculatorPageComponent implements OnInit {
onProceed() {
const res = this.result();
if (res && res.sessionId) {
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{ queryParams: { session: res.sessionId } },
);
const segments = this.cadSessionLocked()
? ['/', this.languageService.selectedLang(), 'checkout', 'cad']
: ['/', this.languageService.selectedLang(), 'checkout'];
this.router.navigate(segments, { queryParams: { session: res.sessionId } });
} else {
console.error('No session ID found in quote result');
// Fallback or error handling
@@ -343,6 +347,7 @@ export class CalculatorPageComponent implements OnInit {
onNewQuote() {
this.step.set('upload');
this.result.set(null);
this.cadSessionLocked.set(false);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
}

View File

@@ -28,6 +28,12 @@
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
@if ((result().cadTotal || 0) > 0) {
<small class="shipping-note" style="color: #666">
Servizio CAD: {{ result().cadTotal | currency: result().currency }}
</small>
<br />
}
<small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate
}}</small>

View File

@@ -120,8 +120,9 @@ export class QuoteResultComponent implements OnDestroy {
totals = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
const cad = this.result().cadTotal || 0;
let price = setup;
let price = setup + cad;
let time = 0;
let weight = 0;

View File

@@ -118,6 +118,12 @@
</div>
<div class="grid">
@if (lockedSettings()) {
<p class="upload-privacy-note">
Parametri stampa bloccati per sessione CAD: materiale, nozzle, layer,
infill e supporti sono definiti dal back-office.
</p>
}
<app-select
formControlName="material"
[label]="'CALC.MATERIAL' | translate"

View File

@@ -5,6 +5,7 @@ import {
signal,
OnInit,
inject,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
@@ -57,6 +58,7 @@ interface FormItem {
})
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
lockedSettings = input<boolean>(false);
loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>();
@@ -139,6 +141,10 @@ export class UploadFormComponent implements OnInit {
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality);
});
effect(() => {
this.applySettingsLock(this.lockedSettings());
});
}
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
@@ -520,7 +526,7 @@ export class UploadFormComponent implements OnInit {
this.form.value,
);
this.submitRequest.emit({
...this.form.value,
...this.form.getRawValue(),
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode(),
});
@@ -554,4 +560,26 @@ export class UploadFormComponent implements OnInit {
private normalizeFileName(fileName: string): string {
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
private applySettingsLock(locked: boolean): void {
const controlsToLock = [
'material',
'quality',
'nozzleDiameter',
'infillPattern',
'layerHeight',
'infillDensity',
'supportEnabled',
];
controlsToLock.forEach((name) => {
const control = this.form.get(name);
if (!control) return;
if (locked) {
control.disable({ emitEvent: false });
} else {
control.enable({ emitEvent: false });
}
});
}
}

View File

@@ -39,6 +39,8 @@ export interface QuoteResult {
items: QuoteItem[];
setupCost: number;
globalMachineCost: number;
cadHours?: number;
cadTotal?: number;
currency: string;
totalPrice: number;
totalTimeHours: number;
@@ -463,6 +465,8 @@ export class QuoteEstimatorService {
})),
setupCost: session.setupCostChf || 0,
globalMachineCost: sessionData.globalMachineCostChf || 0,
cadHours: session.cadHours || 0,
cadTotal: sessionData.cadTotalChf || 0,
currency: 'CHF', // Fixed for now
totalPrice:
(sessionData.itemsTotalChf || 0) +

View File

@@ -1,6 +1,12 @@
<div class="checkout-page">
<div class="container hero">
<h1 class="section-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
<p class="cad-subtitle" *ngIf="isCadSession()">
Servizio CAD
<ng-container *ngIf="cadRequestId()">
riferito alla richiesta contatto #{{ cadRequestId() }}
</ng-container>
</p>
</div>
<div class="container">
@@ -260,6 +266,17 @@
</small>
</div>
</div>
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
<div class="item-details">
<span class="item-name">Servizio CAD</span>
<div class="item-specs-sub">{{ cadHours() }}h</div>
</div>
<div class="item-price">
<span class="item-total-price">
{{ cadTotal() | currency: "CHF" }}
</span>
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">

View File

@@ -8,6 +8,11 @@
}
}
.cad-subtitle {
margin: 0;
color: var(--color-text-muted);
}
.checkout-layout {
display: grid;
grid-template-columns: 1fr 420px;
@@ -260,6 +265,13 @@ app-toggle-selector.user-type-selector-compact {
}
}
.cad-summary-item {
background: var(--color-neutral-100);
border-radius: var(--radius-sm);
padding-left: var(--space-3);
padding-right: var(--space-3);
}
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-4);

View File

@@ -162,6 +162,22 @@ export class CheckoutComponent implements OnInit {
});
}
isCadSession(): boolean {
return this.quoteSession()?.session?.status === 'CAD_ACTIVE';
}
cadRequestId(): string | null {
return this.quoteSession()?.session?.sourceRequestId ?? null;
}
cadHours(): number {
return this.quoteSession()?.session?.cadHours ?? 0;
}
cadTotal(): number {
return this.quoteSession()?.cadTotalChf ?? 0;
}
onSubmit() {
if (this.checkoutForm.invalid) {
return;

View File

@@ -198,6 +198,10 @@
<span>{{ "PAYMENT.SUBTOTAL" | translate }}</span>
<span>{{ o.subtotalChf | currency: "CHF" }}</span>
</div>
<div class="total-row" *ngIf="o.cadTotalChf > 0">
<span>Servizio CAD ({{ o.cadHours || 0 }}h)</span>
<span>{{ o.cadTotalChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ "PAYMENT.SHIPPING" | translate }}</span>
<span>{{ o.shippingCostChf | currency: "CHF" }}</span>