dev #8
@@ -0,0 +1,131 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.CustomQuoteRequest;
|
||||||
|
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||||
|
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
|
||||||
|
import com.printcalculator.repository.CustomQuoteRequestRepository;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/custom-quote-requests")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class CustomQuoteRequestController {
|
||||||
|
|
||||||
|
private final CustomQuoteRequestRepository requestRepo;
|
||||||
|
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
|
||||||
|
|
||||||
|
// TODO: Inject Storage Service
|
||||||
|
private static final String STORAGE_ROOT = "storage_requests";
|
||||||
|
|
||||||
|
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
||||||
|
CustomQuoteRequestAttachmentRepository attachmentRepo) {
|
||||||
|
this.requestRepo = requestRepo;
|
||||||
|
this.attachmentRepo = attachmentRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create Custom Quote Request
|
||||||
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
|
||||||
|
// Form fields
|
||||||
|
@RequestParam("requestType") String requestType,
|
||||||
|
@RequestParam("customerType") String customerType,
|
||||||
|
@RequestParam("email") String email,
|
||||||
|
@RequestParam(value = "phone", required = false) String phone,
|
||||||
|
@RequestParam(value = "name", required = false) String name,
|
||||||
|
@RequestParam(value = "companyName", required = false) String companyName,
|
||||||
|
@RequestParam(value = "contactPerson", required = false) String contactPerson,
|
||||||
|
@RequestParam("message") String message,
|
||||||
|
// Files (Max 15)
|
||||||
|
@RequestParam(value = "files", required = false) List<MultipartFile> files
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
// 1. Create Request
|
||||||
|
CustomQuoteRequest request = new CustomQuoteRequest();
|
||||||
|
request.setRequestType(requestType);
|
||||||
|
request.setCustomerType(customerType);
|
||||||
|
request.setEmail(email);
|
||||||
|
request.setPhone(phone);
|
||||||
|
request.setName(name);
|
||||||
|
request.setCompanyName(companyName);
|
||||||
|
request.setContactPerson(contactPerson);
|
||||||
|
request.setMessage(message);
|
||||||
|
request.setStatus("PENDING");
|
||||||
|
request.setCreatedAt(OffsetDateTime.now());
|
||||||
|
request.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
request = requestRepo.save(request);
|
||||||
|
|
||||||
|
// 2. Handle Attachments
|
||||||
|
if (files != null && !files.isEmpty()) {
|
||||||
|
if (files.size() > 15) {
|
||||||
|
throw new IOException("Too many files. Max 15 allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (file.isEmpty()) continue;
|
||||||
|
|
||||||
|
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
|
||||||
|
attachment.setRequest(request);
|
||||||
|
attachment.setOriginalFilename(file.getOriginalFilename());
|
||||||
|
attachment.setMimeType(file.getContentType());
|
||||||
|
attachment.setFileSizeBytes(file.getSize());
|
||||||
|
attachment.setCreatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
// Generate path
|
||||||
|
UUID fileUuid = UUID.randomUUID();
|
||||||
|
String ext = getExtension(file.getOriginalFilename());
|
||||||
|
String storedFilename = fileUuid.toString() + "." + ext;
|
||||||
|
|
||||||
|
// Note: We don't have attachment ID yet.
|
||||||
|
// We'll save attachment first to get ID.
|
||||||
|
attachment.setStoredFilename(storedFilename);
|
||||||
|
attachment.setStoredRelativePath("PENDING");
|
||||||
|
|
||||||
|
attachment = attachmentRepo.save(attachment);
|
||||||
|
|
||||||
|
String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename;
|
||||||
|
attachment.setStoredRelativePath(relativePath);
|
||||||
|
attachmentRepo.save(attachment);
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
||||||
|
Files.createDirectories(absolutePath.getParent());
|
||||||
|
Files.copy(file.getInputStream(), absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get Request
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) {
|
||||||
|
return requestRepo.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "dat";
|
||||||
|
int i = filename.lastIndexOf('.');
|
||||||
|
if (i > 0) {
|
||||||
|
return filename.substring(i + 1);
|
||||||
|
}
|
||||||
|
return "dat";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.*;
|
||||||
|
import com.printcalculator.repository.*;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/orders")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class OrderController {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final OrderItemRepository orderItemRepo;
|
||||||
|
private final QuoteSessionRepository quoteSessionRepo;
|
||||||
|
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||||
|
private final CustomerRepository customerRepo;
|
||||||
|
|
||||||
|
// TODO: Inject Storage Service or use a base path property
|
||||||
|
private static final String STORAGE_ROOT = "storage_orders";
|
||||||
|
|
||||||
|
public OrderController(OrderRepository orderRepo,
|
||||||
|
OrderItemRepository orderItemRepo,
|
||||||
|
QuoteSessionRepository quoteSessionRepo,
|
||||||
|
QuoteLineItemRepository quoteLineItemRepo,
|
||||||
|
CustomerRepository customerRepo) {
|
||||||
|
this.orderRepo = orderRepo;
|
||||||
|
this.orderItemRepo = orderItemRepo;
|
||||||
|
this.quoteSessionRepo = quoteSessionRepo;
|
||||||
|
this.quoteLineItemRepo = quoteLineItemRepo;
|
||||||
|
this.customerRepo = customerRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
public static class CreateOrderRequest {
|
||||||
|
public CustomerDto customer;
|
||||||
|
public AddressDto billingAddress;
|
||||||
|
public AddressDto shippingAddress;
|
||||||
|
public boolean shippingSameAsBilling;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CustomerDto {
|
||||||
|
public String email;
|
||||||
|
public String phone;
|
||||||
|
public String customerType; // "PRIVATE", "BUSINESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AddressDto {
|
||||||
|
public String firstName;
|
||||||
|
public String lastName;
|
||||||
|
public String companyName;
|
||||||
|
public String contactPerson;
|
||||||
|
public String addressLine1;
|
||||||
|
public String addressLine2;
|
||||||
|
public String zip;
|
||||||
|
public String city;
|
||||||
|
public String countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create Order from Quote
|
||||||
|
@PostMapping("/from-quote/{quoteSessionId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Order> createOrderFromQuote(
|
||||||
|
@PathVariable UUID quoteSessionId,
|
||||||
|
@RequestBody CreateOrderRequest request
|
||||||
|
) {
|
||||||
|
// 1. Fetch Quote Session
|
||||||
|
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
|
||||||
|
|
||||||
|
if (!"ACTIVE".equals(session.getStatus())) {
|
||||||
|
// Allow converting only active sessions? Or check if not already converted?
|
||||||
|
// checking convertedOrderId might be better
|
||||||
|
}
|
||||||
|
if (session.getConvertedOrderId() != null) {
|
||||||
|
return ResponseEntity.badRequest().body(null); // Already converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle Customer (Find or Create)
|
||||||
|
Customer customer = customerRepo.findByEmail(request.customer.email)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Customer newC = new Customer();
|
||||||
|
newC.setEmail(request.customer.email);
|
||||||
|
newC.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return customerRepo.save(newC);
|
||||||
|
});
|
||||||
|
// Update customer details?
|
||||||
|
customer.setPhone(request.customer.phone);
|
||||||
|
customer.setCustomerType(request.customer.customerType);
|
||||||
|
customer.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
customerRepo.save(customer);
|
||||||
|
|
||||||
|
// 3. Create Order
|
||||||
|
Order order = new Order();
|
||||||
|
order.setSourceQuoteSession(session);
|
||||||
|
order.setCustomer(customer);
|
||||||
|
order.setCustomerEmail(request.customer.email);
|
||||||
|
order.setCustomerPhone(request.customer.phone);
|
||||||
|
order.setStatus("PENDING_PAYMENT");
|
||||||
|
order.setCreatedAt(OffsetDateTime.now());
|
||||||
|
order.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
order.setCurrency("CHF");
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
order.setBillingCustomerType(request.customer.customerType);
|
||||||
|
if (request.billingAddress != null) {
|
||||||
|
order.setBillingFirstName(request.billingAddress.firstName);
|
||||||
|
order.setBillingLastName(request.billingAddress.lastName);
|
||||||
|
order.setBillingCompanyName(request.billingAddress.companyName);
|
||||||
|
order.setBillingContactPerson(request.billingAddress.contactPerson);
|
||||||
|
order.setBillingAddressLine1(request.billingAddress.addressLine1);
|
||||||
|
order.setBillingAddressLine2(request.billingAddress.addressLine2);
|
||||||
|
order.setBillingZip(request.billingAddress.zip);
|
||||||
|
order.setBillingCity(request.billingAddress.city);
|
||||||
|
order.setBillingCountryCode(request.billingAddress.countryCode != null ? request.billingAddress.countryCode : "CH");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shipping
|
||||||
|
order.setShippingSameAsBilling(request.shippingSameAsBilling);
|
||||||
|
if (!request.shippingSameAsBilling && request.shippingAddress != null) {
|
||||||
|
order.setShippingFirstName(request.shippingAddress.firstName);
|
||||||
|
order.setShippingLastName(request.shippingAddress.lastName);
|
||||||
|
order.setShippingCompanyName(request.shippingAddress.companyName);
|
||||||
|
order.setShippingContactPerson(request.shippingAddress.contactPerson);
|
||||||
|
order.setShippingAddressLine1(request.shippingAddress.addressLine1);
|
||||||
|
order.setShippingAddressLine2(request.shippingAddress.addressLine2);
|
||||||
|
order.setShippingZip(request.shippingAddress.zip);
|
||||||
|
order.setShippingCity(request.shippingAddress.city);
|
||||||
|
order.setShippingCountryCode(request.shippingAddress.countryCode != null ? request.shippingAddress.countryCode : "CH");
|
||||||
|
} else {
|
||||||
|
// Copy billing to shipping? Or leave empty and rely on flag?
|
||||||
|
// Usually explicit copy is safer for queries
|
||||||
|
order.setShippingFirstName(order.getBillingFirstName());
|
||||||
|
order.setShippingLastName(order.getBillingLastName());
|
||||||
|
order.setShippingCompanyName(order.getBillingCompanyName());
|
||||||
|
order.setShippingContactPerson(order.getBillingContactPerson());
|
||||||
|
order.setShippingAddressLine1(order.getBillingAddressLine1());
|
||||||
|
order.setShippingAddressLine2(order.getBillingAddressLine2());
|
||||||
|
order.setShippingZip(order.getBillingZip());
|
||||||
|
order.setShippingCity(order.getBillingCity());
|
||||||
|
order.setShippingCountryCode(order.getBillingCountryCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Financials from Session (Assuming mocked/calculated in session)
|
||||||
|
// We re-calculate totals from line items to be safe
|
||||||
|
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
|
||||||
|
|
||||||
|
BigDecimal subtotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
// Save Order first to get ID
|
||||||
|
order = orderRepo.save(order);
|
||||||
|
|
||||||
|
// 4. Create Order Items
|
||||||
|
for (QuoteLineItem qItem : quoteItems) {
|
||||||
|
OrderItem oItem = new OrderItem();
|
||||||
|
oItem.setOrder(order);
|
||||||
|
oItem.setOriginalFilename(qItem.getOriginalFilename());
|
||||||
|
oItem.setQuantity(qItem.getQuantity());
|
||||||
|
oItem.setColorCode(qItem.getColorCode());
|
||||||
|
oItem.setMaterialCode(session.getMaterialCode()); // Or per item if supported
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
||||||
|
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||||
|
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
||||||
|
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
||||||
|
|
||||||
|
// File Handling Check
|
||||||
|
// "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}"
|
||||||
|
UUID fileUuid = UUID.randomUUID();
|
||||||
|
String ext = getExtension(qItem.getOriginalFilename());
|
||||||
|
String storedFilename = fileUuid.toString() + "." + ext;
|
||||||
|
|
||||||
|
// Note: We don't have the orderItemId yet because we haven't saved it.
|
||||||
|
// We can pre-generate ID or save order item then update path?
|
||||||
|
// GeneratedValue strategy AUTO might not let us set ID easily?
|
||||||
|
// Let's save item first with temporary path, then update?
|
||||||
|
// OR use a path structure that doesn't depend on ItemId? "orders/{orderId}/3d-files/{uuid}.ext" is also fine?
|
||||||
|
// User requested: "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}"
|
||||||
|
// So we need OrderItemId.
|
||||||
|
|
||||||
|
oItem.setStoredFilename(storedFilename);
|
||||||
|
oItem.setStoredRelativePath("PENDING"); // Placeholder
|
||||||
|
oItem.setMimeType("application/octet-stream"); // specific type if known
|
||||||
|
|
||||||
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
|
||||||
|
// Update Path now that we have ID
|
||||||
|
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||||
|
oItem.setStoredRelativePath(relativePath);
|
||||||
|
orderItemRepo.save(oItem);
|
||||||
|
|
||||||
|
subtotal = subtotal.add(oItem.getLineTotalChf());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Order Totals
|
||||||
|
order.setSubtotalChf(subtotal);
|
||||||
|
order.setSetupCostChf(session.getSetupCostChf());
|
||||||
|
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
|
||||||
|
// TODO: Calc implementation for shipping
|
||||||
|
|
||||||
|
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||||
|
order.setTotalChf(total);
|
||||||
|
|
||||||
|
// Link session
|
||||||
|
session.setConvertedOrderId(order.getId());
|
||||||
|
session.setStatus("CONVERTED"); // or CLOSED
|
||||||
|
quoteSessionRepo.save(session);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(orderRepo.save(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Upload file for Order Item
|
||||||
|
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> uploadOrderItemFile(
|
||||||
|
@PathVariable UUID orderId,
|
||||||
|
@PathVariable UUID orderItemId,
|
||||||
|
@RequestParam("file") MultipartFile file
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
OrderItem item = orderItemRepo.findById(orderItemId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
|
||||||
|
|
||||||
|
if (!item.getOrder().getId().equals(orderId)) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path logic
|
||||||
|
String relativePath = item.getStoredRelativePath();
|
||||||
|
if (relativePath == null || relativePath.equals("PENDING")) {
|
||||||
|
// Should verify consistency
|
||||||
|
// If we used the logic above, it should have a path.
|
||||||
|
// If it's "PENDING", regen it.
|
||||||
|
String ext = getExtension(file.getOriginalFilename());
|
||||||
|
String storedFilename = UUID.randomUUID().toString() + "." + ext;
|
||||||
|
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
|
||||||
|
item.setStoredRelativePath(relativePath);
|
||||||
|
item.setStoredFilename(storedFilename);
|
||||||
|
// Update item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
||||||
|
Files.createDirectories(absolutePath.getParent());
|
||||||
|
|
||||||
|
if (Files.exists(absolutePath)) {
|
||||||
|
Files.delete(absolutePath); // Overwrite?
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.copy(file.getInputStream(), absolutePath);
|
||||||
|
|
||||||
|
item.setFileSizeBytes(file.getSize());
|
||||||
|
item.setMimeType(file.getContentType());
|
||||||
|
// Calculate SHA256? (Optional)
|
||||||
|
|
||||||
|
orderItemRepo.save(item);
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "stl";
|
||||||
|
int i = filename.lastIndexOf('.');
|
||||||
|
if (i > 0) {
|
||||||
|
return filename.substring(i + 1);
|
||||||
|
}
|
||||||
|
return "stl";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
|
import com.printcalculator.entity.QuoteSession;
|
||||||
|
import com.printcalculator.model.PrintStats;
|
||||||
|
import com.printcalculator.model.QuoteResult;
|
||||||
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
|
import com.printcalculator.service.SlicerService;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/quote-sessions")
|
||||||
|
@CrossOrigin(origins = "*") // Allow CORS for dev
|
||||||
|
public class QuoteSessionController {
|
||||||
|
|
||||||
|
private final QuoteSessionRepository sessionRepo;
|
||||||
|
private final QuoteLineItemRepository lineItemRepo;
|
||||||
|
private final SlicerService slicerService;
|
||||||
|
private final QuoteCalculator quoteCalculator;
|
||||||
|
private final PrinterMachineRepository machineRepo;
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
|
private static final String DEFAULT_PROCESS = "standard";
|
||||||
|
|
||||||
|
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||||
|
QuoteLineItemRepository lineItemRepo,
|
||||||
|
SlicerService slicerService,
|
||||||
|
QuoteCalculator quoteCalculator,
|
||||||
|
PrinterMachineRepository machineRepo) {
|
||||||
|
this.sessionRepo = sessionRepo;
|
||||||
|
this.lineItemRepo = lineItemRepo;
|
||||||
|
this.slicerService = slicerService;
|
||||||
|
this.quoteCalculator = quoteCalculator;
|
||||||
|
this.machineRepo = machineRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Start a new session with a file
|
||||||
|
@PostMapping(value = "/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<QuoteSession> createSessionAndAddItem(
|
||||||
|
@RequestParam("file") MultipartFile file
|
||||||
|
) throws IOException {
|
||||||
|
// Create new session
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setPricingVersion("v1"); // Placeholder
|
||||||
|
session.setMaterialCode(DEFAULT_FILAMENT); // Default for session
|
||||||
|
session.setSupportsEnabled(false);
|
||||||
|
session.setCreatedAt(OffsetDateTime.now());
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
|
// Set defaults
|
||||||
|
session.setSetupCostChf(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
session = sessionRepo.save(session);
|
||||||
|
|
||||||
|
// Process file and add item
|
||||||
|
addItemToSession(session, file);
|
||||||
|
|
||||||
|
// Refresh session to return updated data (if we added list fetching to repo, otherwise manually fetch items if needed for response)
|
||||||
|
// For now, let's just return the session. The client might need to fetch items separately or we can return a DTO.
|
||||||
|
// User request: "ritorna sessione + line items + total"
|
||||||
|
// Since QuoteSession entity doesn't have a @OneToMany list of items (it has OneToMany usually but mapped by item),
|
||||||
|
// we might need a DTO or just rely on the fact that we might add the list to the entity if valid.
|
||||||
|
// Looking at QuoteSession.java, it does NOT have a list of items.
|
||||||
|
// So we should probably return a DTO or just return the Session and Client calls GET /quote-sessions/{id} immediately?
|
||||||
|
// User request: "ritorna quoteSessionId" (actually implies just ID, but likely wants full object).
|
||||||
|
// "ritorna sessione + line items + total (usa view o calcolo service)" refers to GET /quote-sessions/{id}
|
||||||
|
|
||||||
|
// Let's return the full session details including items in a DTO/Map/wrapper?
|
||||||
|
// Or just the session for now. The user said "ritorna quoteSessionId" for this specific endpoint.
|
||||||
|
// Let's return the Session entity for now.
|
||||||
|
return ResponseEntity.ok(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add item to existing session
|
||||||
|
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestParam("file") MultipartFile file
|
||||||
|
) throws IOException {
|
||||||
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|
||||||
|
QuoteLineItem item = addItemToSession(session, file);
|
||||||
|
return ResponseEntity.ok(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to add item
|
||||||
|
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file) throws IOException {
|
||||||
|
if (file.isEmpty()) throw new IOException("File is empty");
|
||||||
|
|
||||||
|
// 1. Save file temporarily
|
||||||
|
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||||
|
file.transferTo(tempInput.toFile());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Mock Calc or Real Calc
|
||||||
|
// The user said: "per ora calcolo mock" (mock calculation) but we have SlicerService.
|
||||||
|
// "Nota: il calcolo può essere stub: set print_time_seconds/material_grams/unit_price_chf a valori placeholder."
|
||||||
|
// However, since we have the SlicerService, we CAN try to use it if we want, OR just use stub as requested to be fast?
|
||||||
|
// "avvia calcolo (per ora calcolo mock)" -> I will use a simple Stub to satisfy the requirement immediately.
|
||||||
|
// But I will also implement the structure to swap to Real Calc.
|
||||||
|
|
||||||
|
// STUB CALCULATION as requested
|
||||||
|
int printTime = 3600; // 1 hour
|
||||||
|
BigDecimal materialGrams = new BigDecimal("50.00");
|
||||||
|
BigDecimal unitPrice = new BigDecimal("15.00");
|
||||||
|
|
||||||
|
// 3. Create Line Item
|
||||||
|
QuoteLineItem item = new QuoteLineItem();
|
||||||
|
item.setQuoteSession(session);
|
||||||
|
item.setOriginalFilename(file.getOriginalFilename());
|
||||||
|
item.setQuantity(1);
|
||||||
|
item.setColorCode("#FFFFFF"); // Default
|
||||||
|
item.setStatus("CALCULATED");
|
||||||
|
|
||||||
|
item.setPrintTimeSeconds(printTime);
|
||||||
|
item.setMaterialGrams(materialGrams);
|
||||||
|
item.setUnitPriceChf(unitPrice);
|
||||||
|
item.setPricingBreakdown(Map.of("mock", true));
|
||||||
|
|
||||||
|
// Set simple bounding box
|
||||||
|
item.setBoundingBoxXMm(BigDecimal.valueOf(100));
|
||||||
|
item.setBoundingBoxYMm(BigDecimal.valueOf(100));
|
||||||
|
item.setBoundingBoxZMm(BigDecimal.valueOf(20));
|
||||||
|
|
||||||
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
return lineItemRepo.save(item);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(tempInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update Line Item
|
||||||
|
@PatchMapping("/line-items/{lineItemId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<QuoteLineItem> updateLineItem(
|
||||||
|
@PathVariable UUID lineItemId,
|
||||||
|
@RequestBody Map<String, Object> updates
|
||||||
|
) {
|
||||||
|
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||||
|
|
||||||
|
if (updates.containsKey("quantity")) {
|
||||||
|
item.setQuantity((Integer) updates.get("quantity"));
|
||||||
|
}
|
||||||
|
if (updates.containsKey("color_code")) {
|
||||||
|
item.setColorCode((String) updates.get("color_code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate price if needed?
|
||||||
|
// For now, unit price is fixed in mock. Total is calculated on GET.
|
||||||
|
|
||||||
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
return ResponseEntity.ok(lineItemRepo.save(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete Line Item
|
||||||
|
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteLineItem(
|
||||||
|
@PathVariable UUID sessionId,
|
||||||
|
@PathVariable UUID lineItemId
|
||||||
|
) {
|
||||||
|
// Verify item belongs to session?
|
||||||
|
QuoteLineItem item = lineItemRepo.findById(lineItemId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Item not found"));
|
||||||
|
|
||||||
|
if (!item.getQuoteSession().getId().equals(sessionId)) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItemRepo.delete(item);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Get Session (Session + Items + Total)
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
||||||
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|
||||||
|
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
|
||||||
|
|
||||||
|
// Calculate Totals
|
||||||
|
BigDecimal itemsTotal = BigDecimal.ZERO;
|
||||||
|
for (QuoteLineItem item : items) {
|
||||||
|
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
|
||||||
|
itemsTotal = itemsTotal.add(lineTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
|
||||||
|
BigDecimal grandTotal = itemsTotal.add(setupFee);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("session", session);
|
||||||
|
response.put("items", items);
|
||||||
|
response.put("itemsTotalChf", itemsTotal);
|
||||||
|
response.put("grandTotalChf", grandTotal);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,4 +116,6 @@ public class CustomQuoteRequestAttachment {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setCustomQuoteRequest(CustomQuoteRequest request) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.printcalculator.entity;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import org.hibernate.annotations.Immutable;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Immutable
|
|
||||||
@Table(name = "quote_session_totals")
|
|
||||||
public class QuoteSessionTotal {
|
|
||||||
@Column(name = "quote_session_id")
|
|
||||||
private UUID quoteSessionId;
|
|
||||||
|
|
||||||
@Column(name = "total_chf")
|
|
||||||
private BigDecimal totalChf;
|
|
||||||
|
|
||||||
public UUID getQuoteSessionId() {
|
|
||||||
return quoteSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getTotalChf() {
|
|
||||||
return totalChf;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
|||||||
import com.printcalculator.entity.Customer;
|
import com.printcalculator.entity.Customer;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
|
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
|
||||||
|
Optional<Customer> findByEmail(String email);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
|||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
||||||
|
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,10 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'payment/:orderId',
|
||||||
|
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
21
frontend/src/app/features/payment/payment.component.html
Normal file
21
frontend/src/app/features/payment/payment.component.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="payment-container">
|
||||||
|
<mat-card class="payment-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-icon mat-card-avatar>payment</mat-icon>
|
||||||
|
<mat-card-title>Payment Integration</mat-card-title>
|
||||||
|
<mat-card-subtitle>Order #{{ orderId }}</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="coming-soon">
|
||||||
|
<h3>Coming Soon</h3>
|
||||||
|
<p>The online payment system is currently under development.</p>
|
||||||
|
<p>Your order has been saved. Please contact us to arrange payment.</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<button mat-raised-button color="primary" (click)="completeOrder()">
|
||||||
|
Simulate Payment Completion
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
35
frontend/src/app/features/payment/payment.component.scss
Normal file
35
frontend/src/app/features/payment/payment.component.scss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.payment-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 80vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #777;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #3f51b5;
|
||||||
|
}
|
||||||
34
frontend/src/app/features/payment/payment.component.ts
Normal file
34
frontend/src/app/features/payment/payment.component.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-payment',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
|
||||||
|
templateUrl: './payment.component.html',
|
||||||
|
styleUrl: './payment.component.scss'
|
||||||
|
})
|
||||||
|
export class PaymentComponent implements OnInit {
|
||||||
|
orderId: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
||||||
|
}
|
||||||
|
|
||||||
|
completeOrder(): void {
|
||||||
|
// Simulate payment completion
|
||||||
|
alert('Payment Simulated! Order marked as PAID.');
|
||||||
|
// Here you would call the backend to mark as paid if we had that endpoint ready
|
||||||
|
// For now, redirect home or show success
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user