dev #8
@@ -29,6 +29,8 @@ dependencies {
|
|||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ public class CorsConfig implements WebMvcConfigurer {
|
|||||||
"http://localhost:80",
|
"http://localhost:80",
|
||||||
"http://127.0.0.1",
|
"http://127.0.0.1",
|
||||||
"https://dev.3d-fab.ch",
|
"https://dev.3d-fab.ch",
|
||||||
|
"https://int.3d-fab.ch",
|
||||||
"https://3d-fab.ch"
|
"https://3d-fab.ch"
|
||||||
)
|
)
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,29 +40,20 @@ public class CustomQuoteRequestController {
|
|||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
|
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
|
||||||
// Form fields
|
@RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto,
|
||||||
@RequestParam("requestType") String requestType,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files
|
||||||
@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 {
|
) throws IOException {
|
||||||
|
|
||||||
// 1. Create Request
|
// 1. Create Request
|
||||||
CustomQuoteRequest request = new CustomQuoteRequest();
|
CustomQuoteRequest request = new CustomQuoteRequest();
|
||||||
request.setRequestType(requestType);
|
request.setRequestType(requestDto.getRequestType());
|
||||||
request.setCustomerType(customerType);
|
request.setCustomerType(requestDto.getCustomerType());
|
||||||
request.setEmail(email);
|
request.setEmail(requestDto.getEmail());
|
||||||
request.setPhone(phone);
|
request.setPhone(requestDto.getPhone());
|
||||||
request.setName(name);
|
request.setName(requestDto.getName());
|
||||||
request.setCompanyName(companyName);
|
request.setCompanyName(requestDto.getCompanyName());
|
||||||
request.setContactPerson(contactPerson);
|
request.setContactPerson(requestDto.getContactPerson());
|
||||||
request.setMessage(message);
|
request.setMessage(requestDto.getMessage());
|
||||||
request.setStatus("PENDING");
|
request.setStatus("PENDING");
|
||||||
request.setCreatedAt(OffsetDateTime.now());
|
request.setCreatedAt(OffsetDateTime.now());
|
||||||
request.setUpdatedAt(OffsetDateTime.now());
|
request.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/quote-sessions")
|
@RequestMapping("/api/quote-sessions")
|
||||||
@CrossOrigin(origins = "*") // Allow CORS for dev
|
|
||||||
public class QuoteSessionController {
|
public class QuoteSessionController {
|
||||||
|
|
||||||
private final QuoteSessionRepository sessionRepo;
|
private final QuoteSessionRepository sessionRepo;
|
||||||
@@ -36,6 +36,7 @@ public class QuoteSessionController {
|
|||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
|
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
@@ -45,49 +46,34 @@ public class QuoteSessionController {
|
|||||||
QuoteLineItemRepository lineItemRepo,
|
QuoteLineItemRepository lineItemRepo,
|
||||||
SlicerService slicerService,
|
SlicerService slicerService,
|
||||||
QuoteCalculator quoteCalculator,
|
QuoteCalculator quoteCalculator,
|
||||||
PrinterMachineRepository machineRepo) {
|
PrinterMachineRepository machineRepo,
|
||||||
|
com.printcalculator.repository.PricingPolicyRepository pricingRepo) {
|
||||||
this.sessionRepo = sessionRepo;
|
this.sessionRepo = sessionRepo;
|
||||||
this.lineItemRepo = lineItemRepo;
|
this.lineItemRepo = lineItemRepo;
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
|
this.pricingRepo = pricingRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Start a new session with a file
|
// 1. Start a new empty session
|
||||||
@PostMapping(value = "/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<QuoteSession> createSessionAndAddItem(
|
public ResponseEntity<QuoteSession> createSession() {
|
||||||
@RequestParam("file") MultipartFile file
|
|
||||||
) throws IOException {
|
|
||||||
// Create new session
|
|
||||||
QuoteSession session = new QuoteSession();
|
QuoteSession session = new QuoteSession();
|
||||||
session.setStatus("ACTIVE");
|
session.setStatus("ACTIVE");
|
||||||
session.setPricingVersion("v1"); // Placeholder
|
session.setPricingVersion("v1");
|
||||||
session.setMaterialCode(DEFAULT_FILAMENT); // Default for session
|
// Default material/settings will be set when items are added or updated?
|
||||||
|
// For now set safe defaults
|
||||||
|
session.setMaterialCode("pla_basic");
|
||||||
session.setSupportsEnabled(false);
|
session.setSupportsEnabled(false);
|
||||||
session.setCreatedAt(OffsetDateTime.now());
|
session.setCreatedAt(OffsetDateTime.now());
|
||||||
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
// Set defaults
|
|
||||||
session.setSetupCostChf(BigDecimal.ZERO);
|
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||||
|
session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO);
|
||||||
|
|
||||||
session = sessionRepo.save(session);
|
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);
|
return ResponseEntity.ok(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,17 +82,18 @@ public class QuoteSessionController {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
|
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@RequestParam("file") MultipartFile file
|
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings,
|
||||||
|
@RequestPart("file") MultipartFile file
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
QuoteSession session = sessionRepo.findById(id)
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|
||||||
QuoteLineItem item = addItemToSession(session, file);
|
QuoteLineItem item = addItemToSession(session, file, settings);
|
||||||
return ResponseEntity.ok(item);
|
return ResponseEntity.ok(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to add item
|
// Helper to add item
|
||||||
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file) throws IOException {
|
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
||||||
if (file.isEmpty()) throw new IOException("File is empty");
|
if (file.isEmpty()) throw new IOException("File is empty");
|
||||||
|
|
||||||
// 1. Save file temporarily
|
// 1. Save file temporarily
|
||||||
@@ -114,35 +101,87 @@ public class QuoteSessionController {
|
|||||||
file.transferTo(tempInput.toFile());
|
file.transferTo(tempInput.toFile());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Mock Calc or Real Calc
|
// Apply Basic/Advanced Logic
|
||||||
// The user said: "per ora calcolo mock" (mock calculation) but we have SlicerService.
|
applyPrintSettings(settings);
|
||||||
// "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?
|
// REAL SLICING
|
||||||
// "avvia calcolo (per ora calcolo mock)" -> I will use a simple Stub to satisfy the requirement immediately.
|
// 1. Pick Machine (default to first active or specific)
|
||||||
// But I will also implement the structure to swap to Real Calc.
|
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
|
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||||
|
|
||||||
// STUB CALCULATION as requested
|
// 2. Pick Profiles
|
||||||
int printTime = 3600; // 1 hour
|
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
||||||
BigDecimal materialGrams = new BigDecimal("50.00");
|
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
|
||||||
BigDecimal unitPrice = new BigDecimal("15.00");
|
// For now assuming display name works or we use a tough default
|
||||||
|
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
|
||||||
|
// Ideally: machine.getSlicerProfileName();
|
||||||
|
|
||||||
// 3. Create Line Item
|
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
|
||||||
|
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
|
||||||
|
if (settings.getMaterial() != null) {
|
||||||
|
if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA";
|
||||||
|
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
|
||||||
|
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
|
||||||
|
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
|
||||||
|
}
|
||||||
|
|
||||||
|
String processProfile = "0.20mm Standard @BBL A1";
|
||||||
|
// Mapping quality to process
|
||||||
|
// "standard" -> "0.20mm Standard @BBL A1"
|
||||||
|
// "draft" -> "0.28mm Extra Draft @BBL A1"
|
||||||
|
// "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca)
|
||||||
|
// Let's use robust defaults or simple overrides
|
||||||
|
if (settings.getLayerHeight() != null) {
|
||||||
|
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
|
||||||
|
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build overrides map from settings
|
||||||
|
Map<String, String> processOverrides = new HashMap<>();
|
||||||
|
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
||||||
|
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||||
|
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||||
|
|
||||||
|
// 3. Slice
|
||||||
|
PrintStats stats = slicerService.slice(
|
||||||
|
tempInput.toFile(),
|
||||||
|
machineProfile,
|
||||||
|
filamentProfile,
|
||||||
|
processProfile,
|
||||||
|
null, // machine overrides
|
||||||
|
processOverrides
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Calculate Quote
|
||||||
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||||
|
|
||||||
|
// 5. Create Line Item
|
||||||
QuoteLineItem item = new QuoteLineItem();
|
QuoteLineItem item = new QuoteLineItem();
|
||||||
item.setQuoteSession(session);
|
item.setQuoteSession(session);
|
||||||
item.setOriginalFilename(file.getOriginalFilename());
|
item.setOriginalFilename(file.getOriginalFilename());
|
||||||
item.setQuantity(1);
|
item.setQuantity(1);
|
||||||
item.setColorCode("#FFFFFF"); // Default
|
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
|
||||||
item.setStatus("CALCULATED");
|
item.setStatus("READY"); // or CALCULATED
|
||||||
|
|
||||||
item.setPrintTimeSeconds(printTime);
|
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
|
||||||
item.setMaterialGrams(materialGrams);
|
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
|
||||||
item.setUnitPriceChf(unitPrice);
|
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
||||||
item.setPricingBreakdown(Map.of("mock", true));
|
|
||||||
|
|
||||||
// Set simple bounding box
|
// Store breakdown
|
||||||
item.setBoundingBoxXMm(BigDecimal.valueOf(100));
|
Map<String, Object> breakdown = new HashMap<>();
|
||||||
item.setBoundingBoxYMm(BigDecimal.valueOf(100));
|
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
|
||||||
item.setBoundingBoxZMm(BigDecimal.valueOf(20));
|
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
|
||||||
|
breakdown.put("setup_fee", result.getSetupCost());
|
||||||
|
item.setPricingBreakdown(breakdown);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
// Cannot get bb from GCodeParser yet?
|
||||||
|
// If GCodeParser doesn't return size, we might defaults or 0.
|
||||||
|
// Stats has filament used.
|
||||||
|
// Let's set dummy for now or upgrade parser later.
|
||||||
|
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
||||||
|
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
||||||
|
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
||||||
|
|
||||||
item.setCreatedAt(OffsetDateTime.now());
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
item.setUpdatedAt(OffsetDateTime.now());
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
@@ -154,6 +193,37 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
||||||
|
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||||
|
// Set defaults based on Quality
|
||||||
|
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
|
||||||
|
|
||||||
|
switch (quality) {
|
||||||
|
case "draft":
|
||||||
|
settings.setLayerHeight(0.28);
|
||||||
|
settings.setInfillDensity(15.0);
|
||||||
|
settings.setInfillPattern("grid");
|
||||||
|
break;
|
||||||
|
case "high":
|
||||||
|
settings.setLayerHeight(0.12);
|
||||||
|
settings.setInfillDensity(20.0);
|
||||||
|
settings.setInfillPattern("gyroid");
|
||||||
|
break;
|
||||||
|
case "standard":
|
||||||
|
default:
|
||||||
|
settings.setLayerHeight(0.20);
|
||||||
|
settings.setInfillDensity(20.0);
|
||||||
|
settings.setInfillPattern("grid");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ADVANCED Mode: Use values from Frontend, set defaults if missing
|
||||||
|
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
|
||||||
|
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
|
||||||
|
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Update Line Item
|
// 3. Update Line Item
|
||||||
@PatchMapping("/line-items/{lineItemId}")
|
@PatchMapping("/line-items/{lineItemId}")
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PrintSettingsDto {
|
||||||
|
// Mode: "BASIC" or "ADVANCED"
|
||||||
|
private String complexityMode;
|
||||||
|
|
||||||
|
// Common
|
||||||
|
private String material; // e.g. "PLA", "PETG"
|
||||||
|
private String color; // e.g. "White", "#FFFFFF"
|
||||||
|
|
||||||
|
// Basic Mode
|
||||||
|
private String quality; // "draft", "standard", "high"
|
||||||
|
|
||||||
|
// Advanced Mode (Optional in Basic)
|
||||||
|
private Double layerHeight;
|
||||||
|
private Double infillDensity;
|
||||||
|
private String infillPattern;
|
||||||
|
private Boolean supportsEnabled;
|
||||||
|
private String notes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class QuoteRequestDto {
|
||||||
|
private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE"
|
||||||
|
private String customerType; // "PRIVATE" or "BUSINESS"
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String name;
|
||||||
|
private String companyName;
|
||||||
|
private String contactPerson;
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
40
frontend/src/app/core/services/quote-request.service.ts
Normal file
40
frontend/src/app/core/services/quote-request.service.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export interface QuoteRequestDto {
|
||||||
|
requestType: string;
|
||||||
|
customerType: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class QuoteRequestService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private apiUrl = `${environment.apiUrl}/api/custom-quote-requests`;
|
||||||
|
|
||||||
|
createRequest(request: QuoteRequestDto, files: File[]): Observable<any> {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Append Request DTO as JSON Blob
|
||||||
|
const requestBlob = new Blob([JSON.stringify(request)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
formData.append('request', requestBlob);
|
||||||
|
|
||||||
|
// Append Files
|
||||||
|
files.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.http.post(this.apiUrl, formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,203 +128,135 @@ export class QuoteEstimatorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Observable(observer => {
|
return new Observable(observer => {
|
||||||
const totalItems = request.items.length;
|
// 1. Create Session first
|
||||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
const headers: any = {};
|
||||||
const finalResponses: any[] = [];
|
// @ts-ignore
|
||||||
let completedRequests = 0;
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
|
||||||
const uploads = request.items.map((item, index) => {
|
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
|
||||||
const formData = new FormData();
|
next: (sessionRes) => {
|
||||||
formData.append('file', item.file);
|
const sessionId = sessionRes.id;
|
||||||
// machine param removed - backend uses default active
|
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||||
|
|
||||||
// Map material? Or trust frontend to send correct code?
|
// 2. Upload files to this session
|
||||||
// Since we fetch options now, we should send the code directly.
|
const totalItems = request.items.length;
|
||||||
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
|
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||||
// If frontend sends 'PLA', mapMaterial returns 'pla_basic'.
|
const finalResponses: any[] = [];
|
||||||
// We should check if request.material is already a code from options.
|
let completedRequests = 0;
|
||||||
// For now, let's assume request.material IS the code if it matches our new options,
|
|
||||||
// or fallback to mapper if it's old legacy string.
|
|
||||||
// Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes.
|
|
||||||
// For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'.
|
|
||||||
// Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA').
|
|
||||||
// Backend expects 'pla_basic' or just 'PLA'?
|
|
||||||
// QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'.
|
|
||||||
// So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code.
|
|
||||||
// Backend OptionsController returns type.getMaterialCode() which is 'PLA'.
|
|
||||||
// So we still need mapping to slicer profile names.
|
|
||||||
|
|
||||||
formData.append('filament', this.mapMaterial(request.material));
|
|
||||||
formData.append('quality', this.mapQuality(request.quality));
|
|
||||||
|
|
||||||
// Send color for both modes if present, defaulting to Black
|
|
||||||
formData.append('material_color', item.color || 'Black');
|
|
||||||
|
|
||||||
if (request.mode === 'advanced') {
|
const checkCompletion = () => {
|
||||||
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||||
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
observer.next(avg);
|
||||||
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
|
||||||
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
|
if (completedRequests === totalItems) {
|
||||||
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
|
finalize(finalResponses, sessionSetupCost);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.items.forEach((item, index) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', item.file);
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
complexityMode: request.mode.toUpperCase(),
|
||||||
|
material: this.mapMaterial(request.material),
|
||||||
|
quality: request.quality,
|
||||||
|
supportsEnabled: request.supportEnabled,
|
||||||
|
color: item.color || '#FFFFFF',
|
||||||
|
layerHeight: request.mode === 'advanced' ? request.layerHeight : null,
|
||||||
|
infillDensity: request.mode === 'advanced' ? request.infillDensity : null,
|
||||||
|
infillPattern: request.mode === 'advanced' ? request.infillPattern : null,
|
||||||
|
nozzleDiameter: request.mode === 'advanced' ? request.nozzleDiameter : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' });
|
||||||
|
formData.append('settings', settingsBlob);
|
||||||
|
|
||||||
|
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, {
|
||||||
|
headers,
|
||||||
|
reportProgress: true,
|
||||||
|
observe: 'events'
|
||||||
|
}).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||||
|
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||||
|
checkCompletion();
|
||||||
|
} else if (event.type === HttpEventType.Response) {
|
||||||
|
allProgress[index] = 100;
|
||||||
|
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
|
||||||
|
completedRequests++;
|
||||||
|
checkCompletion();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Item upload failed', err);
|
||||||
|
finalResponses[index] = { success: false, fileName: item.file.name };
|
||||||
|
completedRequests++;
|
||||||
|
checkCompletion();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to create session', err);
|
||||||
|
observer.error('Could not initialize quote session');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalize = (responses: any[], setupCost: number) => {
|
||||||
|
observer.next(100);
|
||||||
|
const items: QuoteItem[] = [];
|
||||||
|
let grandTotal = 0;
|
||||||
|
let totalTime = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
let validCount = 0;
|
||||||
|
|
||||||
|
responses.forEach((res, idx) => {
|
||||||
|
if (!res || !res.success) return;
|
||||||
|
validCount++;
|
||||||
|
|
||||||
|
const unitPrice = res.unitPriceChf || 0;
|
||||||
|
const quantity = res.originalQty || 1;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
fileName: res.fileName,
|
||||||
|
unitPrice: unitPrice,
|
||||||
|
unitTime: res.printTimeSeconds || 0,
|
||||||
|
unitWeight: res.materialGrams || 0,
|
||||||
|
quantity: quantity,
|
||||||
|
material: request.material,
|
||||||
|
color: res.originalItem.color || 'Default'
|
||||||
|
});
|
||||||
|
|
||||||
|
grandTotal += unitPrice * quantity;
|
||||||
|
totalTime += (res.printTimeSeconds || 0) * quantity;
|
||||||
|
totalWeight += (res.materialGrams || 0) * quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validCount === 0) {
|
||||||
|
observer.error('All calculations failed.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: any = {};
|
grandTotal += setupCost;
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
|
|
||||||
return this.http.post<BackendResponse | BackendQuoteResult>(`${environment.apiUrl}/api/quote`, formData, {
|
const result: QuoteResult = {
|
||||||
headers,
|
items,
|
||||||
reportProgress: true,
|
setupCost: setupCost,
|
||||||
observe: 'events'
|
currency: 'CHF',
|
||||||
}).pipe(
|
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||||
map(event => ({ item, event, index })),
|
totalTimeHours: Math.floor(totalTime / 3600),
|
||||||
catchError(err => of({ item, error: err, index }))
|
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||||
);
|
totalWeight: Math.ceil(totalWeight),
|
||||||
});
|
notes: request.notes
|
||||||
|
};
|
||||||
// Subscribe to all
|
|
||||||
uploads.forEach((obs) => {
|
observer.next(result);
|
||||||
obs.subscribe({
|
observer.complete();
|
||||||
next: (wrapper: any) => {
|
};
|
||||||
const idx = wrapper.index;
|
|
||||||
|
|
||||||
if (wrapper.error) {
|
|
||||||
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = wrapper.event;
|
|
||||||
if (event && event.type === HttpEventType.UploadProgress) {
|
|
||||||
if (event.total) {
|
|
||||||
const percent = Math.round((100 * event.loaded) / event.total);
|
|
||||||
allProgress[idx] = percent;
|
|
||||||
// Emit average progress
|
|
||||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
|
||||||
observer.next(avg);
|
|
||||||
}
|
|
||||||
} else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
|
|
||||||
// It's done (either response or error caught above)
|
|
||||||
if (!finalResponses[idx]) { // only if not already set by error
|
|
||||||
allProgress[idx] = 100;
|
|
||||||
if (wrapper.error) {
|
|
||||||
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
|
||||||
} else {
|
|
||||||
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
|
|
||||||
}
|
|
||||||
completedRequests++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completedRequests === totalItems) {
|
|
||||||
// All done
|
|
||||||
observer.next(100);
|
|
||||||
|
|
||||||
// Calculate Results
|
|
||||||
let setupCost = 10;
|
|
||||||
let setupCostFromBackend: number | null = null;
|
|
||||||
let currencyFromBackend: string | null = null;
|
|
||||||
|
|
||||||
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
|
|
||||||
setupCost += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: QuoteItem[] = [];
|
|
||||||
|
|
||||||
finalResponses.forEach((res, idx) => {
|
|
||||||
if (!res) return;
|
|
||||||
const originalItem = request.items[idx];
|
|
||||||
const normalized = this.normalizeResponse(res);
|
|
||||||
if (!normalized.success) return;
|
|
||||||
|
|
||||||
if (normalized.currency && currencyFromBackend == null) {
|
|
||||||
currencyFromBackend = normalized.currency;
|
|
||||||
}
|
|
||||||
if (normalized.setupCost != null && setupCostFromBackend == null) {
|
|
||||||
setupCostFromBackend = normalized.setupCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
fileName: res.fileName,
|
|
||||||
unitPrice: normalized.unitPrice,
|
|
||||||
unitTime: normalized.unitTime,
|
|
||||||
unitWeight: normalized.unitWeight,
|
|
||||||
quantity: res.originalQty, // Use the requested quantity
|
|
||||||
material: request.material,
|
|
||||||
color: originalItem.color || 'Default'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
observer.error('All calculations failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial Aggregation
|
|
||||||
const useBackendSetup = setupCostFromBackend != null;
|
|
||||||
let grandTotal = useBackendSetup ? 0 : setupCost;
|
|
||||||
let totalTime = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
|
|
||||||
items.forEach(item => {
|
|
||||||
grandTotal += item.unitPrice * item.quantity;
|
|
||||||
totalTime += item.unitTime * item.quantity;
|
|
||||||
totalWeight += item.unitWeight * item.quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalHours = Math.floor(totalTime / 3600);
|
|
||||||
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
|
||||||
|
|
||||||
const result: QuoteResult = {
|
|
||||||
items,
|
|
||||||
setupCost: useBackendSetup ? setupCostFromBackend! : setupCost,
|
|
||||||
currency: currencyFromBackend || 'CHF',
|
|
||||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
|
||||||
totalTimeHours: totalHours,
|
|
||||||
totalTimeMinutes: totalMinutes,
|
|
||||||
totalWeight: Math.ceil(totalWeight),
|
|
||||||
notes: request.notes
|
|
||||||
};
|
|
||||||
|
|
||||||
observer.next(result);
|
|
||||||
observer.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error('Error in request subscription', err);
|
|
||||||
completedRequests++;
|
|
||||||
if (completedRequests === totalItems) {
|
|
||||||
observer.error('Requests failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } {
|
|
||||||
if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
unitPrice: res.totalPrice,
|
|
||||||
unitTime: res.stats.printTimeSeconds,
|
|
||||||
unitWeight: res.stats.filamentWeightGrams,
|
|
||||||
setupCost: res.setupCost,
|
|
||||||
currency: res.currency
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res && res.success && res.data) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
unitPrice: res.data.cost.total,
|
|
||||||
unitTime: res.data.print_time_seconds,
|
|
||||||
unitWeight: res.data.material_grams,
|
|
||||||
currency: 'CHF'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapMaterial(mat: string): string {
|
private mapMaterial(mat: string): string {
|
||||||
const m = mat.toUpperCase();
|
const m = mat.toUpperCase();
|
||||||
if (m.includes('PLA')) return 'pla_basic';
|
if (m.includes('PLA')) return 'pla_basic';
|
||||||
@@ -333,13 +265,6 @@ export class QuoteEstimatorService {
|
|||||||
return 'pla_basic';
|
return 'pla_basic';
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapQuality(qual: string): string {
|
|
||||||
const q = qual.toLowerCase();
|
|
||||||
if (q.includes('draft')) return 'draft';
|
|
||||||
if (q.includes('high')) return 'extra_fine';
|
|
||||||
return 'standard';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consultation Data Transfer
|
// Consultation Data Transfer
|
||||||
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
|
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, signal, effect } from '@angular/core';
|
import { Component, signal, effect, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
|
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
|
||||||
|
import { QuoteRequestService } from '../../../../core/services/quote-request.service';
|
||||||
|
|
||||||
interface FilePreview {
|
interface FilePreview {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -37,6 +38,8 @@ export class ContactFormComponent {
|
|||||||
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
|
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private quoteRequestService = inject(QuoteRequestService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
@@ -156,13 +159,34 @@ export class ContactFormComponent {
|
|||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
if (this.form.valid) {
|
if (this.form.valid) {
|
||||||
const formData = {
|
const formVal = this.form.value;
|
||||||
...this.form.value,
|
const isCompany = formVal.isCompany;
|
||||||
files: this.files().map(f => f.file)
|
|
||||||
};
|
const requestDto: any = {
|
||||||
console.log('Form Submit:', formData);
|
requestType: formVal.requestType,
|
||||||
|
customerType: isCompany ? 'BUSINESS' : 'PRIVATE',
|
||||||
|
email: formVal.email,
|
||||||
|
phone: formVal.phone,
|
||||||
|
message: formVal.message
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCompany) {
|
||||||
|
requestDto.companyName = formVal.companyName;
|
||||||
|
requestDto.contactPerson = formVal.referencePerson;
|
||||||
|
} else {
|
||||||
|
requestDto.name = formVal.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sent.set(true);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Submission failed', err);
|
||||||
|
alert('Error submitting request. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.sent.set(true);
|
|
||||||
} else {
|
} else {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user