fix(back-end): fix process and new feature
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.exception.ModelTooLargeException;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.model.StlBounds;
|
||||
import com.printcalculator.repository.PrinterMachineRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.service.SlicerService;
|
||||
import com.printcalculator.service.StlService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -20,6 +23,7 @@ import java.nio.file.Path;
|
||||
public class QuoteController {
|
||||
|
||||
private final SlicerService slicerService;
|
||||
private final StlService stlService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
|
||||
@@ -27,8 +31,9 @@ public class QuoteController {
|
||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||
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.stlService = stlService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
}
|
||||
@@ -117,18 +122,36 @@ public class QuoteController {
|
||||
slicerMachineProfile = "bambu_a1";
|
||||
}
|
||||
|
||||
// Validate model size against machine volume
|
||||
validateModelSize(tempInput.toFile(), machine);
|
||||
|
||||
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||
|
||||
// Calculate Quote (Pass machine display name for pricing lookup)
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return ResponseEntity.internalServerError().build();
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ package com.printcalculator.controller;
|
||||
import com.printcalculator.entity.PrinterMachine;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.exception.ModelTooLargeException;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import com.printcalculator.model.QuoteResult;
|
||||
import com.printcalculator.model.StlBounds;
|
||||
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 com.printcalculator.service.StlService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -37,6 +40,7 @@ public class QuoteSessionController {
|
||||
private final QuoteSessionRepository sessionRepo;
|
||||
private final QuoteLineItemRepository lineItemRepo;
|
||||
private final SlicerService slicerService;
|
||||
private final StlService stlService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PrinterMachineRepository machineRepo;
|
||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||
@@ -49,6 +53,7 @@ public class QuoteSessionController {
|
||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||
QuoteLineItemRepository lineItemRepo,
|
||||
SlicerService slicerService,
|
||||
StlService stlService,
|
||||
QuoteCalculator quoteCalculator,
|
||||
PrinterMachineRepository machineRepo,
|
||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||
@@ -56,6 +61,7 @@ public class QuoteSessionController {
|
||||
this.sessionRepo = sessionRepo;
|
||||
this.lineItemRepo = lineItemRepo;
|
||||
this.slicerService = slicerService;
|
||||
this.stlService = stlService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.machineRepo = machineRepo;
|
||||
this.pricingRepo = pricingRepo;
|
||||
@@ -127,8 +133,11 @@ public class QuoteSessionController {
|
||||
// 1. Pick Machine (default to first active or specific)
|
||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||
|
||||
// 2. Validate model size against machine volume
|
||||
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
|
||||
|
||||
// 2. Pick Profiles
|
||||
// 3. Pick Profiles
|
||||
String machineProfile = machine.getSlicerMachineProfile();
|
||||
if (machineProfile == null || machineProfile.isBlank()) {
|
||||
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()));
|
||||
}
|
||||
|
||||
// 3. Slice (Use persistent path)
|
||||
// 4. Slice (Use persistent path)
|
||||
PrintStats stats = slicerService.slice(
|
||||
persistentPath.toFile(),
|
||||
machineProfile,
|
||||
@@ -204,10 +213,10 @@ public class QuoteSessionController {
|
||||
processOverrides
|
||||
);
|
||||
|
||||
// 4. Calculate Quote
|
||||
// 5. Calculate Quote
|
||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||
|
||||
// 5. Create Line Item
|
||||
// 6. Create Line Item
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setOriginalFilename(file.getOriginalFilename());
|
||||
@@ -227,14 +236,10 @@ public class QuoteSessionController {
|
||||
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);
|
||||
// Dimensions from STL
|
||||
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
|
||||
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
|
||||
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
|
||||
|
||||
item.setCreatedAt(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) {
|
||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||
// Set defaults based on Quality
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
@ControllerAdvice
|
||||
@Slf4j
|
||||
@@ -43,4 +45,27 @@ public class GlobalExceptionHandler {
|
||||
response.put("message", "The uploaded file exceeds the maximum allowed size.");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -50,17 +50,9 @@ public class SlicerService {
|
||||
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
|
||||
if (processOverrides != null) processOverrides.forEach(processProfile::put);
|
||||
|
||||
// MANTENIAMO L'IDENTITÀ BAMBU LAB A1
|
||||
// Ma puliamo solo i riferimenti a mesh esterne che causano il crash grafico (Unable to create exclude triangles)
|
||||
machineProfile.put("printer_model", "Bambu Lab A1");
|
||||
|
||||
// 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");
|
||||
// Evitiamo solo asset grafici opzionali che potrebbero richiedere file esterni
|
||||
if (machineProfile.has("bed_custom_model")) machineProfile.put("bed_custom_model", "");
|
||||
if (machineProfile.has("bed_custom_texture")) machineProfile.put("bed_custom_texture", "");
|
||||
machineProfile.remove("thumbnail");
|
||||
|
||||
Path baseTempPath = Paths.get("/app/temp");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,8 @@
|
||||
<!-- Right Column: Result or Info -->
|
||||
<div class="col-result" #resultCol>
|
||||
|
||||
@if (loading()) {
|
||||
@if (loading() && !result()) {
|
||||
<!-- Initial Loading State (before first result) -->
|
||||
<app-card class="loading-state">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
@@ -54,6 +55,15 @@
|
||||
</div>
|
||||
</app-card>
|
||||
} @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
|
||||
[result]="result()!"
|
||||
(consult)="onConsult()"
|
||||
|
||||
@@ -161,12 +161,14 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (typeof event === 'number') {
|
||||
this.uploadProgress.set(event);
|
||||
} else {
|
||||
// It's the result
|
||||
// It's the result (partial or final)
|
||||
const res = event as QuoteResult;
|
||||
this.result.set(res);
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
this.step.set('quote');
|
||||
|
||||
// Show result immediately if not already showing
|
||||
if (this.step() !== 'quote') {
|
||||
this.step.set('quote');
|
||||
}
|
||||
|
||||
// Sync IDs back to upload form for future updates
|
||||
if (this.uploadForm) {
|
||||
@@ -175,15 +177,23 @@ export class CalculatorPageComponent implements OnInit {
|
||||
|
||||
// Update URL with session ID without reloading
|
||||
if (res.sessionId) {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { session: res.sessionId },
|
||||
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
||||
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
||||
});
|
||||
// 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([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { session: res.sessionId },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
},
|
||||
error: (err) => {
|
||||
if (typeof err === 'string') {
|
||||
this.error.set(err);
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<span class="file-name">{{ item.fileName }}</span>
|
||||
@if (item.error) {
|
||||
<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 {
|
||||
<span class="file-details">
|
||||
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
|
||||
@@ -63,6 +67,10 @@
|
||||
<div class="item-price">
|
||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||
</div>
|
||||
} @else if (item.status === 'pending') {
|
||||
<div class="item-price pending">
|
||||
<div class="spinner-mini"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="item-price error">-</div>
|
||||
}
|
||||
|
||||
@@ -61,9 +61,11 @@ export class QuoteResultComponent {
|
||||
let weight = 0;
|
||||
|
||||
currentItems.forEach(i => {
|
||||
price += i.unitPrice * i.quantity;
|
||||
time += i.unitTime * i.quantity;
|
||||
weight += i.unitWeight * i.quantity;
|
||||
if (i.status === 'done' && !i.error) {
|
||||
price += i.unitPrice * i.quantity;
|
||||
time += i.unitTime * i.quantity;
|
||||
weight += i.unitWeight * i.quantity;
|
||||
}
|
||||
});
|
||||
|
||||
const hours = Math.floor(time / 3600);
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface QuoteItem {
|
||||
material?: string;
|
||||
color?: string;
|
||||
error?: string;
|
||||
status: 'pending' | 'done' | 'error';
|
||||
}
|
||||
|
||||
export interface QuoteResult {
|
||||
@@ -188,18 +189,74 @@ export class QuoteEstimatorService {
|
||||
const sessionId = sessionRes.id;
|
||||
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
|
||||
const totalItems = request.items.length;
|
||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||
const finalResponses: any[] = [];
|
||||
let completedRequests = 0;
|
||||
|
||||
const checkCompletion = () => {
|
||||
const emitUpdate = () => {
|
||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||
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) {
|
||||
finalize(finalResponses, sessionSetupCost, sessionId);
|
||||
observer.complete();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,21 +286,42 @@ export class QuoteEstimatorService {
|
||||
}).subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||
checkCompletion();
|
||||
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
|
||||
emitUpdate();
|
||||
} else if (event.type === HttpEventType.Response) {
|
||||
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++;
|
||||
checkCompletion();
|
||||
emitUpdate();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Item upload failed', err);
|
||||
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++;
|
||||
checkCompletion();
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -253,77 +331,6 @@ export class QuoteEstimatorService {
|
||||
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?
|
||||
// Backend model QuoteSession has materialCode.
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode
|
||||
color: item.colorCode,
|
||||
status: 'done'
|
||||
})),
|
||||
setupCost: session.setupCostChf,
|
||||
currency: 'CHF', // Fixed for now
|
||||
|
||||
Reference in New Issue
Block a user