fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 32s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-16 16:09:36 +01:00
parent 579ac3fcb6
commit 875c6ffd2d
12 changed files with 421 additions and 123 deletions

View File

@@ -1,11 +1,14 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -20,6 +23,7 @@ import java.nio.file.Path;
public class QuoteController { public class QuoteController {
private final SlicerService slicerService; private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
@@ -27,8 +31,9 @@ public class QuoteController {
private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard"; private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) { public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
} }
@@ -117,18 +122,36 @@ public class QuoteController {
slicerMachineProfile = "bambu_a1"; slicerMachineProfile = "bambu_a1";
} }
// Validate model size against machine volume
validateModelSize(tempInput.toFile(), machine);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides); PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup) // Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build();
} finally { } finally {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
} }
} }
private void validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
}
} }

View File

@@ -3,13 +3,16 @@ package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -37,6 +40,7 @@ public class QuoteSessionController {
private final QuoteSessionRepository sessionRepo; private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo; private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService; private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
@@ -49,6 +53,7 @@ public class QuoteSessionController {
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService, SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo, PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
@@ -56,6 +61,7 @@ public class QuoteSessionController {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
@@ -128,7 +134,10 @@ public class QuoteSessionController {
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found")); .orElseThrow(() -> new RuntimeException("No active printer found"));
// 2. Pick Profiles // 2. Validate model size against machine volume
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
// 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile(); String machineProfile = machine.getSlicerMachineProfile();
if (machineProfile == null || machineProfile.isBlank()) { if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
@@ -194,7 +203,7 @@ public class QuoteSessionController {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter())); machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
} }
// 3. Slice (Use persistent path) // 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice( PrintStats stats = slicerService.slice(
persistentPath.toFile(), persistentPath.toFile(),
machineProfile, machineProfile,
@@ -204,10 +213,10 @@ public class QuoteSessionController {
processOverrides processOverrides
); );
// 4. Calculate Quote // 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 5. Create Line Item // 6. 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());
@@ -227,14 +236,10 @@ public class QuoteSessionController {
breakdown.put("setup_fee", result.getSetupCost()); breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown); item.setPricingBreakdown(breakdown);
// Dimensions // Dimensions from STL
// Cannot get bb from GCodeParser yet? item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
// If GCodeParser doesn't return size, we might defaults or 0. item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
// Stats has filament used. item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
// 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());
@@ -250,6 +255,26 @@ public class QuoteSessionController {
} }
} }
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality // Set defaults based on Quality

View File

@@ -9,6 +9,8 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice @ControllerAdvice
@Slf4j @Slf4j
@@ -43,4 +45,27 @@ public class GlobalExceptionHandler {
response.put("message", "The uploaded file exceeds the maximum allowed size."); response.put("message", "The uploaded file exceeds the maximum allowed size.");
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response); return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
} }
@ExceptionHandler(ModelTooLargeException.class)
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "Model too large");
response.put("code", "MODEL_TOO_LARGE");
response.put("message", String.format(
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
));
response.put("model_x_mm", formatMm(exc.getModelX()));
response.put("model_y_mm", formatMm(exc.getModelY()));
response.put("model_z_mm", formatMm(exc.getModelZ()));
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
return ResponseEntity.unprocessableEntity().body(response);
}
private String formatMm(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
} }

View File

@@ -0,0 +1,45 @@
package com.printcalculator.exception;
public class ModelTooLargeException extends RuntimeException {
private final double modelX;
private final double modelY;
private final double modelZ;
private final int buildX;
private final int buildY;
private final int buildZ;
public ModelTooLargeException(double modelX, double modelY, double modelZ,
int buildX, int buildY, int buildZ) {
super("Model size exceeds build volume");
this.modelX = modelX;
this.modelY = modelY;
this.modelZ = modelZ;
this.buildX = buildX;
this.buildY = buildY;
this.buildZ = buildZ;
}
public double getModelX() {
return modelX;
}
public double getModelY() {
return modelY;
}
public double getModelZ() {
return modelZ;
}
public int getBuildX() {
return buildX;
}
public int getBuildY() {
return buildY;
}
public int getBuildZ() {
return buildZ;
}
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.model;
public record StlBounds(double minX, double minY, double minZ,
double maxX, double maxY, double maxZ) {
public double sizeX() {
return maxX - minX;
}
public double sizeY() {
return maxY - minY;
}
public double sizeZ() {
return maxZ - minZ;
}
}

View File

@@ -50,17 +50,9 @@ public class SlicerService {
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put); if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
if (processOverrides != null) processOverrides.forEach(processProfile::put); if (processOverrides != null) processOverrides.forEach(processProfile::put);
// MANTENIAMO L'IDENTITÀ BAMBU LAB A1 // Evitiamo solo asset grafici opzionali che potrebbero richiedere file esterni
// Ma puliamo solo i riferimenti a mesh esterne che causano il crash grafico (Unable to create exclude triangles) if (machineProfile.has("bed_custom_model")) machineProfile.put("bed_custom_model", "");
machineProfile.put("printer_model", "Bambu Lab A1"); if (machineProfile.has("bed_custom_texture")) machineProfile.put("bed_custom_texture", "");
// Impostiamo aree di esclusione vuote esplicitamente per evitare che lo slicer tenti di caricarle dai suoi interni
machineProfile.putArray("bed_exclude_area");
machineProfile.putArray("head_wrap_detect_zone");
// Rimuoviamo i modelli 3D del piatto che richiedono caricamento di mesh STL/OBJ interne
machineProfile.remove("bed_custom_model");
machineProfile.remove("bed_custom_texture");
machineProfile.remove("thumbnail"); machineProfile.remove("thumbnail");
Path baseTempPath = Paths.get("/app/temp"); Path baseTempPath = Paths.get("/app/temp");

View File

@@ -0,0 +1,134 @@
package com.printcalculator.service;
import com.printcalculator.model.StlBounds;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@Service
public class StlService {
public StlBounds readBounds(File stlFile) throws IOException {
long size = stlFile.length();
if (size >= 84 && isBinaryStl(stlFile, size)) {
return readBinaryBounds(stlFile);
}
return readAsciiBounds(stlFile);
}
private boolean isBinaryStl(File stlFile, long size) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
long expected = 84L + triangleCount * 50L;
return expected == size;
}
}
private StlBounds readBinaryBounds(File stlFile) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
raf.seek(84);
BoundsAccumulator acc = new BoundsAccumulator();
for (long i = 0; i < triangleCount; i++) {
// skip normal
readLEFloat(raf);
readLEFloat(raf);
readLEFloat(raf);
// 3 vertices
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
// skip attribute byte count
raf.skipBytes(2);
}
return acc.toBounds();
}
}
private StlBounds readAsciiBounds(File stlFile) throws IOException {
BoundsAccumulator acc = new BoundsAccumulator();
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (!line.startsWith("vertex")) continue;
String[] parts = line.split("\\s+");
if (parts.length < 4) continue;
double x = Double.parseDouble(parts[1]);
double y = Double.parseDouble(parts[2]);
double z = Double.parseDouble(parts[3]);
acc.accept(x, y, z);
}
}
return acc.toBounds();
}
private long readLEUInt32(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return ((long) b1 & 0xFF)
| (((long) b2 & 0xFF) << 8)
| (((long) b3 & 0xFF) << 16)
| (((long) b4 & 0xFF) << 24);
}
private int readLEInt(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return (b1 & 0xFF)
| ((b2 & 0xFF) << 8)
| ((b3 & 0xFF) << 16)
| ((b4 & 0xFF) << 24);
}
private float readLEFloat(RandomAccessFile raf) throws IOException {
return Float.intBitsToFloat(readLEInt(raf));
}
private static class BoundsAccumulator {
private boolean hasPoint = false;
private double minX;
private double minY;
private double minZ;
private double maxX;
private double maxY;
private double maxZ;
void accept(double x, double y, double z) {
if (!hasPoint) {
minX = maxX = x;
minY = maxY = y;
minZ = maxZ = z;
hasPoint = true;
return;
}
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
StlBounds toBounds() throws IOException {
if (!hasPoint) {
throw new IOException("STL appears to contain no vertices");
}
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
}
}

View File

@@ -45,7 +45,8 @@
<!-- Right Column: Result or Info --> <!-- Right Column: Result or Info -->
<div class="col-result" #resultCol> <div class="col-result" #resultCol>
@if (loading()) { @if (loading() && !result()) {
<!-- Initial Loading State (before first result) -->
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <div class="loader-content">
<div class="spinner"></div> <div class="spinner"></div>
@@ -54,6 +55,15 @@
</div> </div>
</app-card> </app-card>
} @else if (result()) { } @else if (result()) {
<!-- Result State (Active or Finished) -->
@if (loading()) {
<!-- Small loader indicator when refining results -->
<div class="analyzing-bar">
<div class="spinner-small"></div>
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
</div>
}
<app-quote-result <app-quote-result
[result]="result()!" [result]="result()!"
(consult)="onConsult()" (consult)="onConsult()"

View File

@@ -161,12 +161,14 @@ export class CalculatorPageComponent implements OnInit {
if (typeof event === 'number') { if (typeof event === 'number') {
this.uploadProgress.set(event); this.uploadProgress.set(event);
} else { } else {
// It's the result // It's the result (partial or final)
const res = event as QuoteResult; const res = event as QuoteResult;
this.result.set(res); this.result.set(res);
this.loading.set(false);
this.uploadProgress.set(100); // Show result immediately if not already showing
if (this.step() !== 'quote') {
this.step.set('quote'); this.step.set('quote');
}
// Sync IDs back to upload form for future updates // Sync IDs back to upload form for future updates
if (this.uploadForm) { if (this.uploadForm) {
@@ -175,14 +177,22 @@ export class CalculatorPageComponent implements OnInit {
// Update URL with session ID without reloading // Update URL with session ID without reloading
if (res.sessionId) { if (res.sessionId) {
// Check if we need to update URL to avoid redundant navigations
const currentSession = this.route.snapshot.queryParamMap.get('session');
if (currentSession !== res.sessionId) {
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { session: res.sessionId }, queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any queryParamsHandling: 'merge',
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update" replaceUrl: true
}); });
} }
} }
}
},
complete: () => {
this.loading.set(false);
this.uploadProgress.set(100);
}, },
error: (err) => { error: (err) => {
if (typeof err === 'string') { if (typeof err === 'string') {

View File

@@ -41,6 +41,10 @@
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
@if (item.error) { @if (item.error) {
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span> <span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
} @else if (item.status === 'pending') {
<span class="file-details pending">
<div class="spinner-mini"></div> Analisi...
</span>
} @else { } @else {
<span class="file-details"> <span class="file-details">
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span> <span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
@@ -63,6 +67,10 @@
<div class="item-price"> <div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }} {{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div> </div>
} @else if (item.status === 'pending') {
<div class="item-price pending">
<div class="spinner-mini"></div>
</div>
} @else { } @else {
<div class="item-price error">-</div> <div class="item-price error">-</div>
} }

View File

@@ -61,9 +61,11 @@ export class QuoteResultComponent {
let weight = 0; let weight = 0;
currentItems.forEach(i => { currentItems.forEach(i => {
if (i.status === 'done' && !i.error) {
price += i.unitPrice * i.quantity; price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity; time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity; weight += i.unitWeight * i.quantity;
}
}); });
const hours = Math.floor(time / 3600); const hours = Math.floor(time / 3600);

View File

@@ -27,6 +27,7 @@ export interface QuoteItem {
material?: string; material?: string;
color?: string; color?: string;
error?: string; error?: string;
status: 'pending' | 'done' | 'error';
} }
export interface QuoteResult { export interface QuoteResult {
@@ -188,18 +189,74 @@ export class QuoteEstimatorService {
const sessionId = sessionRes.id; const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0; const sessionSetupCost = sessionRes.setupCostChf || 0;
// Initialize items in pending state
const currentItems: QuoteItem[] = request.items.map(item => ({
fileName: item.file.name,
unitPrice: 0,
unitTime: 0,
unitWeight: 0,
quantity: item.quantity,
status: 'pending',
color: item.color || 'White' // Default color for UI
}));
// Emit initial state
const initialResult: QuoteResult = {
sessionId: sessionId,
items: [...currentItems],
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: 0, // Will be calculated dynamically
totalTimeHours: 0,
totalTimeMinutes: 0,
totalWeight: 0,
notes: request.notes
};
observer.next(initialResult);
// 2. Upload files to this session // 2. Upload files to this session
const totalItems = request.items.length; const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0); const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0; let completedRequests = 0;
const checkCompletion = () => { const emitUpdate = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg); observer.next(avg);
// Helper to calculate totals for current items
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
currentItems.forEach(item => {
if (item.status === 'done') {
grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity;
totalWeight += item.unitWeight * item.quantity;
validCount++;
}
});
if (validCount > 0) {
grandTotal += sessionSetupCost;
}
const result: QuoteResult = {
sessionId: sessionId,
items: [...currentItems], // Create copy to trigger change detection
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
if (completedRequests === totalItems) { if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId); observer.complete();
} }
}; };
@@ -229,21 +286,42 @@ export class QuoteEstimatorService {
}).subscribe({ }).subscribe({
next: (event) => { next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) { if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((100 * event.loaded) / event.total); allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
checkCompletion(); emitUpdate();
} else if (event.type === HttpEventType.Response) { } else if (event.type === HttpEventType.Response) {
allProgress[index] = 100; allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item }; const resBody = event.body as any;
// Update item in list
currentItems[index] = {
id: resBody.id,
fileName: resBody.originalFilename, // use returned filename
unitPrice: resBody.unitPriceChf || 0,
unitTime: resBody.printTimeSeconds || 0,
unitWeight: resBody.materialGrams || 0,
quantity: item.quantity, // Keep original quantity
material: request.material,
color: item.color || 'White',
status: 'done'
};
completedRequests++; completedRequests++;
checkCompletion(); emitUpdate();
} }
}, },
error: (err) => { error: (err) => {
console.error('Item upload failed', err); console.error('Item upload failed', err);
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED'; const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
finalResponses[index] = { success: false, fileName: item.file.name, error: errorMsg };
currentItems[index] = {
...currentItems[index],
status: 'error',
error: errorMsg
};
allProgress[index] = 100; // Mark as done despite error
completedRequests++; completedRequests++;
checkCompletion(); emitUpdate();
} }
}); });
}); });
@@ -253,77 +331,6 @@ export class QuoteEstimatorService {
observer.error('Could not initialize quote session'); observer.error('Could not initialize quote session');
} }
}); });
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
observer.next(100);
const items: QuoteItem[] = [];
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
responses.forEach((res, idx) => {
const quantity = res?.originalQty || request.items[idx].quantity || 1;
if (!res || !res.success) {
items.push({
fileName: request.items[idx].file.name,
unitPrice: 0,
unitTime: 0,
unitWeight: 0,
quantity: quantity,
error: res?.error || 'UPLOAD_FAILED'
});
return;
}
validCount++;
const unitPrice = res.unitPriceChf || 0;
items.push({
id: res.id,
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) {
// Check if any failed due to virus
const virusError = responses.find(r => r.error === 'VIRUS_DETECTED');
if (virusError) {
observer.error('VIRUS_DETECTED');
} else {
observer.error('All calculations failed.');
}
return;
}
grandTotal += setupCost;
const result: QuoteResult = {
sessionId: sessionId,
items,
setupCost: setupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
observer.complete();
};
}); });
} }
@@ -377,7 +384,8 @@ export class QuoteEstimatorService {
material: session.materialCode, // Assumption: session has one material for all? or items have it? material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode. // Backend model QuoteSession has materialCode.
// But line items might have different colors. // But line items might have different colors.
color: item.colorCode color: item.colorCode,
status: 'done'
})), })),
setupCost: session.setupCostChf, setupCost: session.setupCostChf,
currency: 'CHF', // Fixed for now currency: 'CHF', // Fixed for now