33 Commits

Author SHA1 Message Date
8f2d21c0e1 Merge branch 'dev' into feat/calculator-options
Some checks failed
PR Checks / test-backend (pull_request) Failing after 20s
PR Checks / prettier-autofix (pull_request) Failing after 6s
PR Checks / security-sast (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Failing after 55s
2026-03-05 17:12:36 +01:00
8e23bd97e6 fix(tutto rotto): dai che si fixa
Some checks failed
PR Checks / prettier-autofix (pull_request) Failing after 7s
PR Checks / test-backend (pull_request) Failing after 21s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Failing after 55s
2026-03-05 17:07:25 +01:00
71424f086e Merge branch 'fix/twint' into feat/calculator-options
# Conflicts:
#	backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java
2026-03-05 17:05:45 +01:00
b2edf5ec4c fix(tutto rotto): dai che si fixa 2026-03-05 17:05:15 +01:00
8c61990827 fix(tutto rotto): 2026-03-05 16:46:24 +01:00
54b50028b1 fix(back-end): path solver
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m1s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-backend (pull_request) Failing after 22s
PR Checks / security-sast (pull_request) Successful in 30s
2026-03-05 16:37:54 +01:00
9facf05c10 fix(back-end): twint url
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 15:44:03 +01:00
fe3951b6c3 feat(front-end): calculator improvements 2026-03-05 15:43:37 +01:00
1effd4926f Merge pull request 'feat/calculator-options' (#23) from feat/calculator-options into dev
All checks were successful
Build and Deploy / build-and-push (push) Successful in 45s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #23
2026-03-05 15:14:08 +01:00
printcalc-ci
d061f21d79 style: apply prettier formatting 2026-03-05 14:07:58 +00:00
266fab5e17 feat(front-end): alt improvements
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 15s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-05 15:07:05 +01:00
a4b85b01bd feat(front-end): calculator improvements 2026-03-05 15:02:26 +01:00
30e28cb019 feat(front-end): seo 2026-03-05 15:01:56 +01:00
1a36808d9f feat(front-end and back-end): new nozle option, also fix quantity reload and reorganized service in back-end 2026-03-05 15:01:40 +01:00
8a57aa78fb Merge pull request 'dev' (#22) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #22
2026-03-05 09:31:09 +01:00
de9e473cca fix(front-end): button calculator
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / deploy (push) Successful in 11s
2026-03-05 08:32:06 +01:00
a7f58175fa Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
2026-03-05 08:32:01 +01:00
460b878fbb fix(front-end): button calculator 2026-03-05 08:31:55 +01:00
4a8925df13 Merge pull request 'feat(back-end and front-end) 3d visualization for cad' (#21) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #21
2026-03-04 16:57:49 +01:00
db3619e889 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 13s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-04 16:54:25 +01:00
printcalc-ci
5e5a3949d4 style: apply prettier formatting 2026-03-04 15:53:21 +00:00
0ef97eeb9b feat(back-end and front-end) 3d visualization for cad
All checks were successful
Build and Deploy / test-backend (push) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 43s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-04 16:49:18 +01:00
6149e4ac43 Merge pull request 'dev' (#20) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 23s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #20
2026-03-04 15:33:02 +01:00
printcalc-ci
57360bacd0 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-04 14:21:33 +00:00
db3708aef6 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 12s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-04 15:19:40 +01:00
2050ff35f4 feat(back-end and front-end) email
Some checks failed
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 44s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Failing after 10s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-04 15:12:28 +01:00
038e79e52a Merge pull request 'dev' (#18) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #18
2026-03-04 15:03:12 +01:00
6f47d02813 feat(back-end and front-end) email
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 35s
Build and Deploy / build-and-push (push) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 12s
2026-03-04 14:55:51 +01:00
3916f3ace6 feat(back-end and front-end) email for request
All checks were successful
Build and Deploy / test-backend (push) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 37s
Build and Deploy / deploy (push) Successful in 9s
2026-03-04 14:45:09 +01:00
df3fecf722 Merge pull request 'dev' (#17) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #17
2026-03-04 13:54:16 +01:00
2c4fa570e1 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
PR Checks / prettier-autofix (pull_request) Successful in 11s
Build and Deploy / test-frontend (push) Successful in 1m10s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 10s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-04 13:50:55 +01:00
ab2229ec8b Merge pull request 'feat/cad-bill' (#16) from feat/cad-bill into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 44s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m7s
Reviewed-on: #16
2026-03-04 12:45:58 +01:00
d9931a6fae Merge pull request 'dev' (#15) from dev into main
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #15
2026-03-04 11:02:43 +01:00
73 changed files with 4408 additions and 1003 deletions

View File

@@ -41,25 +41,38 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: "frontend/package-lock.json" cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium - name: Resolve Chrome binary
shell: bash shell: bash
run: | run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update apt-get update
apt-get install -y --no-install-recommends chromium apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies - name: Install frontend dependencies
shell: bash shell: bash
run: | run: |
cd frontend cd frontend
npm ci --no-audit --no-fund npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless) - name: Run frontend tests (headless)
shell: bash shell: bash
env: env:
CHROME_BIN: /usr/bin/chromium
CI: "true" CI: "true"
run: | run: |
cd frontend cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push: build-and-push:

View File

@@ -150,23 +150,36 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: "frontend/package-lock.json" cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium - name: Resolve Chrome binary
shell: bash shell: bash
run: | run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update apt-get update
apt-get install -y --no-install-recommends chromium apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies - name: Install frontend dependencies
shell: bash shell: bash
run: | run: |
cd frontend cd frontend
npm ci --no-audit --no-fund npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless) - name: Run frontend tests (headless)
shell: bash shell: bash
env: env:
CHROME_BIN: /usr/bin/chromium
CI: "true" CI: "true"
run: | run: |
cd frontend cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

View File

@@ -5,7 +5,7 @@ import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService; import com.printcalculator.service.storage.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -3,17 +3,16 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse; import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.LayerHeightOption;
import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile; import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver; import com.printcalculator.service.OrcaProfileResolver;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -24,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -32,26 +32,26 @@ public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo; private final PrinterMachineRepository printerMachineRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver; private final OrcaProfileResolver orcaProfileResolver;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo, NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo, PrinterMachineRepository printerMachineRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) { OrcaProfileResolver orcaProfileResolver,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo; this.printerMachineRepo = printerMachineRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo; this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver; this.orcaProfileResolver = orcaProfileResolver;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
@@ -116,15 +116,6 @@ public class OptionsController {
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> Boolean.TRUE.equals(l.getIsActive()))
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm())
))
.toList();
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(n.getIsActive())) .filter(n -> Boolean.TRUE.equals(n.getIsActive()))
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
@@ -137,7 +128,31 @@ public class OptionsController {
)) ))
.toList(); .toList();
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle();
BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
List<OptionsResponse.LayerHeightOptionDTO> layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of()));
if (layers.isEmpty()) {
layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
}
List<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = rulesByNozzle.entrySet().stream()
.map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
entry.getKey().doubleValue(),
toLayerDtos(entry.getValue())
))
.toList();
return ResponseEntity.ok(new OptionsResponse(
materialOptions,
qualities,
patterns,
layers,
nozzles,
layerHeightsByNozzle
));
} }
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
@@ -152,9 +167,9 @@ public class OptionsController {
return Set.of(); return Set.of();
} }
BigDecimal nozzle = nozzleDiameter != null BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
? BigDecimal.valueOf(nozzleDiameter) nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
: BigDecimal.valueOf(0.40); );
PrinterMachineProfile machineProfile = orcaProfileResolver PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle) .resolveMachineProfile(machine, nozzle)
@@ -172,6 +187,16 @@ public class OptionsController {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private List<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) {
return layers.stream()
.sorted(Comparator.naturalOrder())
.map(layer -> new OptionsResponse.LayerHeightOptionDTO(
layer.doubleValue(),
String.format("%.2f mm", layer)
))
.toList();
}
private String resolveHexColor(FilamentVariant variant) { private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex(); return variant.getColorHex();

View File

@@ -3,12 +3,12 @@ package com.printcalculator.controller;
import com.printcalculator.dto.*; import com.printcalculator.dto.*;
import com.printcalculator.entity.*; import com.printcalculator.entity.*;
import com.printcalculator.repository.*; import com.printcalculator.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.TwintPaymentService; import com.printcalculator.service.payment.TwintPaymentService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -27,6 +27,7 @@ import java.util.UUID;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.Base64; import java.util.Base64;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.net.URI; import java.net.URI;
import java.util.Locale; import java.util.Locale;
@@ -36,6 +37,11 @@ import java.util.regex.Pattern;
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> PERSONAL_DATA_REDACTED_STATUSES = Set.of(
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED"
);
private final OrderService orderService; private final OrderService orderService;
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
@@ -292,10 +298,13 @@ public class OrderController {
dto.setPaymentMethod(p.getMethod()); dto.setPaymentMethod(p.getMethod());
}); });
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
if (!redactPersonalData) {
dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone()); dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType()); dto.setBillingCustomerType(order.getBillingCustomerType());
}
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setCurrency(order.getCurrency()); dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf()); dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf()); dto.setShippingCostChf(order.getShippingCostChf());
@@ -310,6 +319,7 @@ public class OrderController {
dto.setCreatedAt(order.getCreatedAt()); dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
if (!redactPersonalData) {
AddressDto billing = new AddressDto(); AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName()); billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName()); billing.setLastName(order.getBillingLastName());
@@ -335,6 +345,7 @@ public class OrderController {
shipping.setCountryCode(order.getShippingCountryCode()); shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping); dto.setShippingAddress(shipping);
} }
}
List<OrderItemDto> itemDtos = items.stream().map(i -> { List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto(); OrderItemDto idto = new OrderItemDto();
@@ -342,6 +353,12 @@ public class OrderController {
idto.setOriginalFilename(i.getOriginalFilename()); idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode()); idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode()); idto.setColorCode(i.getColorCode());
idto.setQuality(i.getQuality());
idto.setNozzleDiameterMm(i.getNozzleDiameterMm());
idto.setLayerHeightMm(i.getLayerHeightMm());
idto.setInfillPercent(i.getInfillPercent());
idto.setInfillPattern(i.getInfillPattern());
idto.setSupportsEnabled(i.getSupportsEnabled());
idto.setQuantity(i.getQuantity()); idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams()); idto.setMaterialGrams(i.getMaterialGrams());
@@ -354,6 +371,13 @@ public class OrderController {
return dto; return dto;
} }
private boolean shouldRedactPersonalData(String status) {
if (status == null || status.isBlank()) {
return false;
}
return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {

View File

@@ -4,17 +4,23 @@ import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
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;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
public class QuoteController { public class QuoteController {
@@ -22,17 +28,23 @@ public class QuoteController {
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.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
// Defaults (using aliases defined in ProfileManager) // Defaults (using aliases defined in ProfileManager)
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, com.printcalculator.service.ClamAVService clamAVService) { public QuoteController(SlicerService slicerService,
QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
ClamAVService clamAVService,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -69,15 +81,27 @@ public class QuoteController {
if (infillPattern != null && !infillPattern.isEmpty()) { if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern); processOverrides.put("sparse_infill_pattern", infillPattern);
} }
BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (layerHeight != null) { if (layerHeight != null) {
processOverrides.put("layer_height", String.valueOf(layerHeight)); BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight));
if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Layer height " + normalizedLayer.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle)
);
}
processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString());
} }
if (supportEnabled != null) { if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0"); processOverrides.put("enable_support", supportEnabled ? "1" : "0");
} }
if (nozzleDiameter != null) { if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter)); machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString());
// Also need to ensure the printer profile is compatible or just override? // Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides. // Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile. // For now, we trust the override key works on the base profile.

View File

@@ -1,103 +1,68 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentVariant;
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.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
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.OrcaProfileResolver;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.QuoteSessionTotalsService;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.quote.QuoteSessionItemService;
import com.printcalculator.service.quote.QuoteSessionResponseAssembler;
import com.printcalculator.service.quote.QuoteStorageService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.Optional;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private final QuoteSessionRepository sessionRepo; private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo; private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final OrcaProfileResolver orcaProfileResolver;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
private final QuoteSessionTotalsService quoteSessionTotalsService; private final QuoteSessionTotalsService quoteSessionTotalsService;
private final QuoteSessionItemService quoteSessionItemService;
private final QuoteStorageService quoteStorageService;
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService, QuoteSessionTotalsService quoteSessionTotalsService,
QuoteSessionTotalsService quoteSessionTotalsService) { QuoteSessionItemService quoteSessionItemService,
QuoteStorageService quoteStorageService,
QuoteSessionResponseAssembler quoteSessionResponseAssembler) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService;
this.quoteSessionTotalsService = quoteSessionTotalsService; this.quoteSessionTotalsService = quoteSessionTotalsService;
this.quoteSessionItemService = quoteSessionItemService;
this.quoteStorageService = quoteStorageService;
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
} }
// 1. Start a new empty session
@PostMapping(value = "") @PostMapping(value = "")
@Transactional @Transactional
public ResponseEntity<QuoteSession> createSession() { public ResponseEntity<QuoteSession> createSession() {
QuoteSession session = new QuoteSession(); QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE"); session.setStatus("ACTIVE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated?
// For now set safe defaults
session.setMaterialCode("PLA"); session.setMaterialCode("PLA");
session.setSupportsEnabled(false); session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now()); session.setCreatedAt(OffsetDateTime.now());
@@ -110,277 +75,143 @@ public class QuoteSessionController {
return ResponseEntity.ok(session); return ResponseEntity.ok(session);
} }
// 2. Add item to existing session
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<QuoteLineItem> addItemToExistingSession( public ResponseEntity<QuoteLineItem> addItemToExistingSession(@PathVariable UUID id,
@PathVariable UUID id, @RequestPart("settings") PrintSettingsDto settings,
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, @RequestPart("file") MultipartFile file) throws IOException {
@RequestPart("file") MultipartFile file
) 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, settings); QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings);
return ResponseEntity.ok(item); return ResponseEntity.ok(item);
} }
// Helper to add item @PatchMapping("/line-items/{lineItemId}")
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { @Transactional
if (file.isEmpty()) throw new IllegalArgumentException("File is empty"); public ResponseEntity<QuoteLineItem> updateLineItem(@PathVariable UUID lineItemId,
@RequestBody Map<String, Object> updates) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
QuoteSession session = item.getQuoteSession();
if ("CONVERTED".equals(session.getStatus())) { if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session"); throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
} }
// Scan for virus if (updates.containsKey("quantity")) {
clamAVService.scan(file.getInputStream()); item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
// 1. Define Persistent Storage Path
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
} }
Files.createDirectories(sessionStorageDir); if (updates.containsKey("color_code")) {
Object colorValue = updates.get("color_code");
String originalFilename = file.getOriginalFilename(); if (colorValue != null) {
String ext = getSafeExtension(originalFilename, "stl"); item.setColorCode(String.valueOf(colorValue));
String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize();
if (!persistentPath.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path");
}
// Save file
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
Path convertedPersistentPath = null;
try {
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus());
// In CAD sessions, print settings are locked server-side.
if (cadSession) {
enforceCadPrintSettings(session, settings);
} else {
applyPrintSettings(settings);
}
BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4);
// Pick machine (selected machine if provided, otherwise first active)
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId());
// Resolve selected filament variant
FilamentVariant selectedVariant = resolveFilamentVariant(settings);
if (cadSession
&& session.getMaterialCode() != null
&& selectedVariant.getFilamentMaterialType() != null
&& selectedVariant.getFilamentMaterialType().getMaterialCode() != null) {
String lockedMaterial = normalizeRequestedMaterialCode(session.getMaterialCode());
String selectedMaterial = normalizeRequestedMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
if (!lockedMaterial.equals(selectedMaterial)) {
throw new ResponseStatusException(BAD_REQUEST, "Selected filament does not match locked CAD material");
} }
} }
// Update session global settings from the most recent item added
if (!cadSession) {
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2));
session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
sessionRepo.save(session);
}
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
String machineProfile = profiles.machineProfileName();
String filamentProfile = profiles.filamentProfileName();
String processProfile = "standard";
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "draft";
else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine";
}
// 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());
Path slicerInputPath = persistentPath;
if ("3mf".equals(ext)) {
String convertedFilename = UUID.randomUUID() + "-converted.stl";
convertedPersistentPath = sessionStorageDir.resolve(convertedFilename).normalize();
if (!convertedPersistentPath.startsWith(sessionStorageDir)) {
throw new IOException("Invalid converted STL storage path");
}
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
slicerInputPath = convertedPersistentPath;
}
// 3. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
slicerInputPath.toFile(),
machineProfile,
filamentProfile,
processProfile,
null, // machine overrides
processOverrides
);
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
// 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root)
item.setQuantity(1);
item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
breakdown.put("setup_fee", 0);
if (convertedPersistentPath != null) {
breakdown.put("convertedStoredPath", QUOTE_STORAGE_ROOT.relativize(convertedPersistentPath).toString());
}
item.setPricingBreakdown(breakdown);
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
item.setBoundingBoxXMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.xMm()))
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
item.setBoundingBoxYMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.yMm()))
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
item.setBoundingBoxZMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.zMm()))
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
return ResponseEntity.ok(lineItemRepo.save(item));
return lineItemRepo.save(item);
} catch (Exception e) {
// Cleanup if failed
Files.deleteIfExists(persistentPath);
if (convertedPersistentPath != null) {
Files.deleteIfExists(convertedPersistentPath);
} }
throw e;
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
@Transactional
public ResponseEntity<Void> deleteLineItem(@PathVariable UUID sessionId,
@PathVariable UUID lineItemId) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
lineItemRepo.delete(item);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found"));
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals));
}
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<Resource> downloadLineItemContent(@PathVariable UUID sessionId,
@PathVariable UUID lineItemId,
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview)
throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
String targetStoredPath = item.getStoredPath();
if (preview) {
String convertedPath = quoteStorageService.extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
} }
} }
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { if (targetStoredPath == null) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { return ResponseEntity.notFound().build();
// 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(15.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");
}
} }
private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.PrintSettingsDto settings) { java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
settings.setComplexityMode("ADVANCED"); if (path == null || !java.nio.file.Files.exists(path)) {
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA"); return ResponseEntity.notFound().build();
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
} }
private PrinterMachine resolvePrinterMachine(Long printerMachineId) { Resource resource = new UrlResource(path.toUri());
if (printerMachineId != null) { String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename();
PrinterMachine selected = machineRepo.findById(printerMachineId)
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId)); return ResponseEntity.ok()
if (!Boolean.TRUE.equals(selected.getIsActive())) { .contentType(MediaType.APPLICATION_OCTET_STREAM)
throw new RuntimeException("Selected printer machine is not active"); .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
} .body(resource);
return selected;
} }
return machineRepo.findFirstByIsActiveTrue() @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
.orElseThrow(() -> new RuntimeException("No active printer found")); public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
@PathVariable UUID lineItemId)
throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
} }
private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) { if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
if (settings.getFilamentVariantId() != null) { return ResponseEntity.notFound().build();
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
.orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId()));
if (!Boolean.TRUE.equals(variant.getIsActive())) {
throw new RuntimeException("Selected filament variant is not active");
}
return variant;
} }
String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial()); String targetStoredPath = item.getStoredPath();
if (targetStoredPath == null || targetStoredPath.isBlank()) {
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode) return ResponseEntity.notFound().build();
.orElseGet(() -> materialRepo.findByMaterialCode("PLA")
.orElseThrow(() -> new RuntimeException("Fallback material PLA not configured")));
String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null;
if (requestedColor != null && !requestedColor.isBlank()) {
Optional<FilamentVariant> byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor);
if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) {
return byColor.get();
}
} }
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode)); if (path == null || !java.nio.file.Files.exists(path)) {
return ResponseEntity.notFound().build();
} }
private String normalizeRequestedMaterialCode(String value) { if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) {
if (value == null || value.isBlank()) { return ResponseEntity.notFound().build();
return "PLA";
} }
return value.trim() Resource resource = new UrlResource(path.toUri());
.toUpperCase(Locale.ROOT) String downloadName = path.getFileName().toString();
.replace('_', ' ')
.replace('-', ' ') return ResponseEntity.ok()
.replaceAll("\\s+", " "); .contentType(MediaType.parseMediaType("model/stl"))
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
.body(resource);
} }
private int parsePositiveQuantity(Object raw) { private int parsePositiveQuantity(Object raw) {
@@ -408,198 +239,4 @@ public class QuoteSessionController {
} }
return quantity; return quantity;
} }
// 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}")
@Transactional
public ResponseEntity<QuoteLineItem> updateLineItem(
@PathVariable UUID lineItemId,
@RequestBody Map<String, Object> updates
) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
QuoteSession session = item.getQuoteSession();
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
}
if (updates.containsKey("quantity")) {
item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
}
if (updates.containsKey("color_code")) {
Object colorValue = updates.get("color_code");
if (colorValue != null) {
item.setColorCode(String.valueOf(colorValue));
}
}
// Recalculate price if needed?
// For now, unit price is fixed in mock. Total is calculated on GET.
item.setUpdatedAt(OffsetDateTime.now());
return ResponseEntity.ok(lineItemRepo.save(item));
}
// 4. Delete Line Item
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
@Transactional
public ResponseEntity<Void> deleteLineItem(
@PathVariable UUID sessionId,
@PathVariable UUID lineItemId
) {
// Verify item belongs to session?
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
lineItemRepo.delete(item);
return ResponseEntity.noContent().build();
}
// 5. Get Session (Session + Items + Total)
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found"));
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
// Map items to DTO to embed distributed machine cost
List<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
Map<String, Object> dto = new HashMap<>();
dto.put("id", item.getId());
dto.put("originalFilename", item.getOriginalFilename());
dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("status", item.getStatus());
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1;
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
unitPrice = unitPrice.add(unitMachineCost);
}
dto.put("unitPriceChf", unitPrice);
itemsDto.add(dto);
}
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", itemsDto);
response.put("printItemsTotalChf", totals.printItemsTotalChf());
response.put("cadTotalChf", totals.cadTotalChf());
response.put("itemsTotalChf", totals.itemsTotalChf());
response.put("shippingCostChf", totals.shippingCostChf());
response.put("globalMachineCostChf", totals.globalMachineCostChf());
response.put("grandTotalChf", totals.grandTotalChf());
return ResponseEntity.ok(response);
}
// 6. Download Line Item Content
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
@PathVariable UUID sessionId,
@PathVariable UUID lineItemId,
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
) throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
String targetStoredPath = item.getStoredPath();
if (preview) {
String convertedPath = extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
}
}
if (targetStoredPath == null) {
return ResponseEntity.notFound().build();
}
Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
if (path == null || !Files.exists(path)) {
return ResponseEntity.notFound().build();
}
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
String downloadName = item.getOriginalFilename();
if (preview) {
downloadName = path.getFileName().toString();
}
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
.body(resource);
}
private String getSafeExtension(String filename, String fallback) {
if (filename == null) {
return fallback;
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return fallback;
}
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) {
return fallback;
}
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private String extractConvertedStoredPath(QuoteLineItem item) {
Map<String, Object> breakdown = item.getPricingBreakdown();
if (breakdown == null) {
return null;
}
Object converted = breakdown.get("convertedStoredPath");
if (converted == null) {
return null;
}
String path = String.valueOf(converted).trim();
return path.isEmpty() ? null : path;
}
} }

View File

@@ -4,18 +4,19 @@ import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto; import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order; import com.printcalculator.entity.*;
import com.printcalculator.entity.OrderItem; import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -30,9 +31,11 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException; import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@@ -45,6 +48,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
@RequestMapping("/api/admin/orders") @RequestMapping("/api/admin/orders")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOrderController { public class AdminOrderController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final List<String> ALLOWED_ORDER_STATUSES = List.of( private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT", "PENDING_PAYMENT",
"PAID", "PAID",
@@ -57,27 +61,33 @@ public class AdminOrderController {
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo; private final PaymentRepository paymentRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final PaymentService paymentService; private final PaymentService paymentService;
private final StorageService storageService; private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public AdminOrderController( public AdminOrderController(
OrderRepository orderRepo, OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo, PaymentRepository paymentRepo,
QuoteLineItemRepository quoteLineItemRepo,
PaymentService paymentService, PaymentService paymentService,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService QrBillService qrBillService,
ApplicationEventPublisher eventPublisher
) { ) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo; this.paymentRepo = paymentRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.paymentService = paymentService; this.paymentService = paymentService;
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
} }
@GetMapping @GetMapping
@@ -96,13 +106,16 @@ public class AdminOrderController {
@PostMapping("/{orderId}/payments/confirm") @PostMapping("/{orderId}/payments/confirm")
@Transactional @Transactional
public ResponseEntity<OrderDto> confirmPayment( public ResponseEntity<OrderDto> updatePaymentMethod(
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload @RequestBody(required = false) Map<String, String> payload
) { ) {
getOrderOrThrow(orderId); getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null; String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method); if (method == null || method.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Payment method is required");
}
paymentService.updatePaymentMethod(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
} }
@@ -124,10 +137,16 @@ public class AdminOrderController {
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
); );
} }
String previousStatus = order.getStatus();
order.setStatus(normalizedStatus); order.setStatus(normalizedStatus);
orderRepo.save(order); Order savedOrder = orderRepo.save(order);
return ResponseEntity.ok(toOrderDto(order)); // Notify customer only on transition to SHIPPED.
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
}
return ResponseEntity.ok(toOrderDto(savedOrder));
} }
@GetMapping("/{orderId}/items/{orderItemId}/file") @GetMapping("/{orderId}/items/{orderItemId}/file")
@@ -326,6 +345,98 @@ public class AdminOrderController {
} }
} }
private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) {
try {
return storageService.loadAsResource(safeRelativePath);
} catch (Exception primaryFailure) {
Path sourceQuotePath = resolveFallbackQuoteItemPath(item);
if (sourceQuotePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
storageService.store(sourceQuotePath, safeRelativePath);
return storageService.loadAsResource(safeRelativePath);
} catch (Exception copyFailure) {
try {
Resource quoteResource = new UrlResource(sourceQuotePath.toUri());
if (quoteResource.exists() || quoteResource.isReadable()) {
return quoteResource;
}
} catch (Exception ignored) {
// fall through to 404
}
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
}
private Path resolveFallbackQuoteItemPath(OrderItem orderItem) {
Order order = orderItem.getOrder();
QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null;
UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null;
if (sourceSessionId == null) {
return null;
}
String targetFilename = normalizeFilename(orderItem.getOriginalFilename());
if (targetFilename == null) {
return null;
}
return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream()
.filter(q -> targetFilename.equals(normalizeFilename(q.getOriginalFilename())))
.sorted(Comparator.comparingInt((QuoteLineItem q) -> scoreQuoteMatch(orderItem, q)).reversed())
.map(q -> resolveStoredQuotePath(q.getStoredPath(), sourceSessionId))
.filter(path -> path != null && Files.exists(path))
.findFirst()
.orElse(null);
}
private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) {
int score = 0;
if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) {
score += 4;
}
if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) {
score += 3;
}
if (orderItem.getMaterialCode() != null
&& quoteItem.getMaterialCode() != null
&& orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) {
score += 3;
}
if (orderItem.getMaterialGrams() != null
&& quoteItem.getMaterialGrams() != null
&& orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) {
score += 2;
}
return score;
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return null;
}
return filename.trim();
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) { private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
} }

View File

@@ -7,7 +7,8 @@ public record OptionsResponse(
List<QualityOption> qualities, List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns, List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights, List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters List<NozzleOptionDTO> nozzleDiameters,
List<NozzleLayerHeightOptionsDTO> layerHeightsByNozzle
) { ) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {} public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption( public record VariantOption(
@@ -24,4 +25,5 @@ public record OptionsResponse(
public record InfillPatternOption(String id, String label) {} public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {} public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {}
public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List<LayerHeightOptionDTO> layerHeights) {}
} }

View File

@@ -8,6 +8,12 @@ public class OrderItemDto {
private String originalFilename; private String originalFilename;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private String quality;
private BigDecimal nozzleDiameterMm;
private BigDecimal layerHeightMm;
private Integer infillPercent;
private String infillPattern;
private Boolean supportsEnabled;
private Integer quantity; private Integer quantity;
private Integer printTimeSeconds; private Integer printTimeSeconds;
private BigDecimal materialGrams; private BigDecimal materialGrams;
@@ -27,6 +33,24 @@ public class OrderItemDto {
public String getColorCode() { return colorCode; } public String getColorCode() { return colorCode; }
public void setColorCode(String colorCode) { this.colorCode = colorCode; } public void setColorCode(String colorCode) { this.colorCode = colorCode; }
public String getQuality() { return quality; }
public void setQuality(String quality) { this.quality = quality; }
public BigDecimal getNozzleDiameterMm() { return nozzleDiameterMm; }
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { this.nozzleDiameterMm = nozzleDiameterMm; }
public BigDecimal getLayerHeightMm() { return layerHeightMm; }
public void setLayerHeightMm(BigDecimal layerHeightMm) { this.layerHeightMm = layerHeightMm; }
public Integer getInfillPercent() { return infillPercent; }
public void setInfillPercent(Integer infillPercent) { this.infillPercent = infillPercent; }
public String getInfillPattern() { return infillPattern; }
public void setInfillPattern(String infillPattern) { this.infillPattern = infillPattern; }
public Boolean getSupportsEnabled() { return supportsEnabled; }
public void setSupportsEnabled(Boolean supportsEnabled) { this.supportsEnabled = supportsEnabled; }
public Integer getQuantity() { return quantity; } public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; } public void setQuantity(Integer quantity) { this.quantity = quantity; }

View File

@@ -1,8 +1,5 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data;
@Data
public class PrintSettingsDto { public class PrintSettingsDto {
// Mode: "BASIC" or "ADVANCED" // Mode: "BASIC" or "ADVANCED"
private String complexityMode; private String complexityMode;
@@ -28,4 +25,124 @@ public class PrintSettingsDto {
private Double boundingBoxX; private Double boundingBoxX;
private Double boundingBoxY; private Double boundingBoxY;
private Double boundingBoxZ; private Double boundingBoxZ;
public String getComplexityMode() {
return complexityMode;
}
public void setComplexityMode(String complexityMode) {
this.complexityMode = complexityMode;
}
public String getMaterial() {
return material;
}
public void setMaterial(String material) {
this.material = material;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public Long getPrinterMachineId() {
return printerMachineId;
}
public void setPrinterMachineId(Long printerMachineId) {
this.printerMachineId = printerMachineId;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public Double getNozzleDiameter() {
return nozzleDiameter;
}
public void setNozzleDiameter(Double nozzleDiameter) {
this.nozzleDiameter = nozzleDiameter;
}
public Double getLayerHeight() {
return layerHeight;
}
public void setLayerHeight(Double layerHeight) {
this.layerHeight = layerHeight;
}
public Double getInfillDensity() {
return infillDensity;
}
public void setInfillDensity(Double infillDensity) {
this.infillDensity = infillDensity;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Double getBoundingBoxX() {
return boundingBoxX;
}
public void setBoundingBoxX(Double boundingBoxX) {
this.boundingBoxX = boundingBoxX;
}
public Double getBoundingBoxY() {
return boundingBoxY;
}
public void setBoundingBoxY(Double boundingBoxY) {
this.boundingBoxY = boundingBoxY;
}
public Double getBoundingBoxZ() {
return boundingBoxZ;
}
public void setBoundingBoxZ(Double boundingBoxZ) {
this.boundingBoxZ = boundingBoxZ;
}
} }

View File

@@ -0,0 +1,63 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(
name = "nozzle_layer_height_option",
uniqueConstraints = @UniqueConstraint(
name = "ux_nozzle_layer_height_option_nozzle_layer",
columnNames = {"nozzle_diameter_mm", "layer_height_mm"}
)
)
public class NozzleLayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_layer_height_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -44,6 +44,24 @@ public class OrderItem {
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode; private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id") @JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@@ -162,6 +180,54 @@ public class OrderItem {
this.materialCode = materialCode; this.materialCode = materialCode;
} }
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public FilamentVariant getFilamentVariant() { public FilamentVariant getFilamentVariant() {
return filamentVariant; return filamentVariant;
} }

View File

@@ -45,6 +45,27 @@ public class QuoteLineItem {
@com.fasterxml.jackson.annotation.JsonIgnore @com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@Column(name = "material_code", length = Integer.MAX_VALUE)
private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 6, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3) @Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm; private BigDecimal boundingBoxXMm;
@@ -137,6 +158,62 @@ public class QuoteLineItem {
this.filamentVariant = filamentVariant; this.filamentVariant = filamentVariant;
} }
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public BigDecimal getBoundingBoxXMm() { public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm; return boundingBoxXMm;
} }

View File

@@ -0,0 +1,16 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderShippedEvent extends ApplicationEvent {
private final Order order;
public OrderShippedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -4,12 +4,13 @@ import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem; import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.event.PaymentConfirmedEvent; import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -95,6 +96,19 @@ public class OrderEmailListener {
} }
} }
@Async
@EventListener
public void handleOrderShippedEvent(OrderShippedEvent event) {
Order order = event.getOrder();
log.info("Processing OrderShippedEvent for order id: {}", order.getId());
try {
sendOrderShippedEmail(order);
} catch (Exception e) {
log.error("Failed to send order shipped email for order id: {}", order.getId(), e);
}
}
private void sendCustomerConfirmationEmail(Order order) { private void sendCustomerConfirmationEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage()); String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order); String orderNumber = getDisplayOrderNumber(order);
@@ -153,6 +167,21 @@ public class OrderEmailListener {
); );
} }
private void sendOrderShippedEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyOrderShippedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
subject,
"order-shipped",
templateData
);
}
private void sendAdminNotificationEmail(Order order) { private void sendAdminNotificationEmail(Order order) {
String orderNumber = getDisplayOrderNumber(order); String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE); Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
@@ -381,6 +410,63 @@ public class OrderEmailListener {
}; };
} }
private String applyOrderShippedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Shipped");
templateData.put("headlineText", "Your order #" + orderNumber + " has been shipped");
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Good news: your package has left our workshop and is on its way.");
templateData.put("statusText", "Current status: Shipped.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "If you need assistance, reply to this email.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Your order has been shipped (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellung versandt");
templateData.put("headlineText", "Ihre Bestellung #" + orderNumber + " wurde versandt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Gute Nachricht: Ihr Paket hat unsere Werkstatt verlassen und ist unterwegs.");
templateData.put("statusText", "Aktueller Status: Versandt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wenn Sie Hilfe benoetigen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Ihre Bestellung wurde versandt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Commande expediee");
templateData.put("headlineText", "Votre commande #" + orderNumber + " a ete expediee");
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Bonne nouvelle: votre colis a quitte notre atelier et est en route.");
templateData.put("statusText", "Statut actuel: Expediee.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Votre commande a ete expediee (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Ordine spedito");
templateData.put("headlineText", "Il tuo ordine #" + orderNumber + " e' stato spedito");
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Buone notizie: il tuo pacco e' partito dal nostro laboratorio ed e' in viaggio.");
templateData.put("statusText", "Stato attuale: spedito.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Se hai bisogno di assistenza, rispondi a questa email.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Il tuo ordine e' stato spedito (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {

View File

@@ -0,0 +1,10 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleLayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NozzleLayerHeightOptionRepository extends JpaRepository<NozzleLayerHeightOption, Long> {
List<NozzleLayerHeightOption> findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
}

View File

@@ -0,0 +1,143 @@
package com.printcalculator.service;
import com.printcalculator.entity.NozzleLayerHeightOption;
import com.printcalculator.repository.NozzleLayerHeightOptionRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class NozzleLayerHeightPolicyService {
private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP);
private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP);
private final NozzleLayerHeightOptionRepository ruleRepo;
public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) {
this.ruleRepo = ruleRepo;
}
public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() {
List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
if (rules.isEmpty()) {
return fallbackRules();
}
Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>();
for (NozzleLayerHeightOption rule : rules) {
BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm());
BigDecimal layer = normalizeLayer(rule.getLayerHeightMm());
if (nozzle == null || layer == null) {
continue;
}
byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer);
}
byNozzle.values().forEach(this::sortAndDeduplicate);
return byNozzle;
}
public BigDecimal normalizeNozzle(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal normalizeLayer(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(3, RoundingMode.HALF_UP);
}
public BigDecimal resolveNozzle(BigDecimal requestedNozzle) {
return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE);
}
public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) {
if (requestedLayer != null) {
return normalizeLayer(requestedLayer);
}
return defaultLayerForNozzle(nozzleDiameter);
}
public List<BigDecimal> allowedLayersForNozzle(BigDecimal nozzleDiameter) {
BigDecimal nozzle = resolveNozzle(nozzleDiameter);
List<BigDecimal> allowed = getActiveRulesByNozzle().get(nozzle);
return allowed != null ? allowed : List.of();
}
public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
BigDecimal layer = normalizeLayer(layerHeight);
if (layer == null) {
return false;
}
return allowedLayersForNozzle(nozzleDiameter)
.stream()
.anyMatch(allowed -> allowed.compareTo(layer) == 0);
}
public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return DEFAULT_LAYER;
}
BigDecimal preferred = normalizeLayer(DEFAULT_LAYER);
for (BigDecimal candidate : allowed) {
if (candidate.compareTo(preferred) == 0) {
return candidate;
}
}
return allowed.get(0);
}
public String allowedLayersLabel(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return "none";
}
return allowed.stream()
.map(value -> String.format(Locale.ROOT, "%.2f", value))
.reduce((a, b) -> a + ", " + b)
.orElse("none");
}
private void sortAndDeduplicate(List<BigDecimal> values) {
values.sort(Comparator.naturalOrder());
for (int i = values.size() - 1; i > 0; i--) {
if (values.get(i).compareTo(values.get(i - 1)) == 0) {
values.remove(i);
}
}
}
private Map<BigDecimal, List<BigDecimal>> fallbackRules() {
Map<BigDecimal, List<BigDecimal>> fallback = new LinkedHashMap<>();
fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12));
fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28));
fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36));
fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56));
return fallback;
}
private BigDecimal scaleNozzle(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
}
private List<BigDecimal> scaleLayers(double... values) {
List<BigDecimal> scaled = new ArrayList<>();
for (double value : values) {
scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP));
}
return scaled;
}
}

View File

@@ -8,6 +8,10 @@ import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -16,6 +20,7 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@@ -23,6 +28,7 @@ import java.util.*;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -178,6 +184,12 @@ public class OrderService {
} else { } else {
oItem.setMaterialCode(session.getMaterialCode()); oItem.setMaterialCode(session.getMaterialCode());
} }
oItem.setQuality(qItem.getQuality());
oItem.setNozzleDiameterMm(qItem.getNozzleDiameterMm());
oItem.setLayerHeightMm(qItem.getLayerHeightMm());
oItem.setInfillPercent(qItem.getInfillPercent());
oItem.setInfillPattern(qItem.getInfillPattern());
oItem.setSupportsEnabled(qItem.getSupportsEnabled());
BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO; BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO;
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {
@@ -210,16 +222,15 @@ public class OrderService {
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath); oItem.setStoredRelativePath(relativePath);
if (qItem.getStoredPath() != null) { Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
if (sourcePath == null || !Files.exists(sourcePath)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
}
try { try {
Path sourcePath = Paths.get(qItem.getStoredPath());
if (Files.exists(sourcePath)) {
storageService.store(sourcePath, Paths.get(relativePath)); storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath)); oItem.setFileSizeBytes(Files.size(sourcePath));
}
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
@@ -291,6 +302,23 @@ public class OrderService {
return "stl"; return "stl";
} }
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer; import com.openhtmltopdf.svgsupport.BatikSVGDrawer;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
@@ -65,7 +65,7 @@ public class PaymentService {
payment.setReportedAt(OffsetDateTime.now()); payment.setReportedAt(OffsetDateTime.now());
// We intentionally do not update the payment method here based on user input, // We intentionally do not update the payment method here based on user input,
// because the user cannot reliably determine the actual method without an integration. // because the system cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually. // It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment); payment = paymentRepo.save(payment);
@@ -98,4 +98,20 @@ public class PaymentService {
return payment; return payment;
} }
@Transactional
public Payment updatePaymentMethod(UUID orderId, String method) {
if (method == null || method.isBlank()) {
throw new IllegalArgumentException("Payment method is required");
}
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
payment.setMethod(method.trim().toUpperCase());
return paymentRepo.save(payment);
}
} }

View File

@@ -1,13 +1,10 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import net.codecrete.qrbill.generator.Bill; import net.codecrete.qrbill.generator.Bill;
import net.codecrete.qrbill.generator.GraphicsFormat;
import net.codecrete.qrbill.generator.QRBill; import net.codecrete.qrbill.generator.QRBill;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service @Service
public class QrBillService { public class QrBillService {

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import io.nayuki.qrcodegen.QrCode; import io.nayuki.qrcodegen.QrCode;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;

View File

@@ -0,0 +1,251 @@
package com.printcalculator.service.quote;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@Service
public class QuoteSessionItemService {
private final QuoteLineItemRepository lineItemRepo;
private final QuoteSessionRepository sessionRepo;
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
private final OrcaProfileResolver orcaProfileResolver;
private final ClamAVService clamAVService;
private final QuoteStorageService quoteStorageService;
private final QuoteSessionSettingsService settingsService;
public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo,
QuoteSessionRepository sessionRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator,
OrcaProfileResolver orcaProfileResolver,
ClamAVService clamAVService,
QuoteStorageService quoteStorageService,
QuoteSessionSettingsService settingsService) {
this.lineItemRepo = lineItemRepo;
this.sessionRepo = sessionRepo;
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
this.orcaProfileResolver = orcaProfileResolver;
this.clamAVService = clamAVService;
this.quoteStorageService = quoteStorageService;
this.settingsService = settingsService;
}
public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
}
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
}
clamAVService.scan(file.getInputStream());
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
Path convertedPersistentPath = null;
try {
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus());
if (cadSession) {
settingsService.enforceCadPrintSettings(session, settings);
} else {
settingsService.applyPrintSettings(settings);
}
QuoteSessionSettingsService.NozzleLayerSettings nozzleAndLayer = settingsService.resolveNozzleAndLayer(settings);
BigDecimal nozzleDiameter = nozzleAndLayer.nozzleDiameter();
BigDecimal layerHeight = nozzleAndLayer.layerHeight();
PrinterMachine machine = settingsService.resolvePrinterMachine(settings.getPrinterMachineId());
FilamentVariant selectedVariant = settingsService.resolveFilamentVariant(settings);
validateCadMaterialLock(session, cadSession, selectedVariant);
if (!cadSession) {
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(layerHeight);
session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
sessionRepo.save(session);
}
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
String processProfile = resolveProcessProfile(settings);
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString());
if (settings.getInfillDensity() != null) {
processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
}
if (settings.getInfillPattern() != null) {
processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
}
Path slicerInputPath = persistentPath;
if ("3mf".equals(ext)) {
String convertedFilename = UUID.randomUUID() + "-converted.stl";
convertedPersistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, convertedFilename);
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
slicerInputPath = convertedPersistentPath;
}
PrintStats stats = slicerService.slice(
slicerInputPath.toFile(),
profiles.machineProfileName(),
profiles.filamentProfileName(),
processProfile,
null,
processOverrides
);
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
QuoteLineItem item = buildLineItem(
session,
file.getOriginalFilename(),
settings,
selectedVariant,
nozzleDiameter,
layerHeight,
stats,
result,
modelDimensions,
persistentPath,
convertedPersistentPath
);
return lineItemRepo.save(item);
} catch (Exception e) {
Files.deleteIfExists(persistentPath);
if (convertedPersistentPath != null) {
Files.deleteIfExists(convertedPersistentPath);
}
throw e;
}
}
private void validateCadMaterialLock(QuoteSession session, boolean cadSession, FilamentVariant selectedVariant) {
if (!cadSession
|| session.getMaterialCode() == null
|| selectedVariant.getFilamentMaterialType() == null
|| selectedVariant.getFilamentMaterialType().getMaterialCode() == null) {
return;
}
String lockedMaterial = settingsService.normalizeRequestedMaterialCode(session.getMaterialCode());
String selectedMaterial = settingsService.normalizeRequestedMaterialCode(
selectedVariant.getFilamentMaterialType().getMaterialCode()
);
if (!lockedMaterial.equals(selectedMaterial)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected filament does not match locked CAD material");
}
}
private String resolveProcessProfile(PrintSettingsDto settings) {
if (settings.getLayerHeight() == null) {
return "standard";
}
if (settings.getLayerHeight() >= 0.28) {
return "draft";
}
if (settings.getLayerHeight() <= 0.12) {
return "extra_fine";
}
return "standard";
}
private QuoteLineItem buildLineItem(QuoteSession session,
String originalFilename,
PrintSettingsDto settings,
FilamentVariant selectedVariant,
BigDecimal nozzleDiameter,
BigDecimal layerHeight,
PrintStats stats,
QuoteResult result,
Optional<ModelDimensions> modelDimensions,
Path persistentPath,
Path convertedPersistentPath) {
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(originalFilename);
item.setStoredPath(quoteStorageService.toStoredPath(persistentPath));
item.setQuantity(1);
item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null
? selectedVariant.getFilamentMaterialType().getMaterialCode()
: settingsService.normalizeRequestedMaterialCode(settings.getMaterial()));
item.setQuality(settingsService.resolveQuality(settings, layerHeight));
item.setNozzleDiameterMm(nozzleDiameter);
item.setLayerHeightMm(layerHeight);
item.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
item.setInfillPattern(settings.getInfillPattern());
item.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
item.setStatus("READY");
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice());
breakdown.put("setup_fee", 0);
if (convertedPersistentPath != null) {
breakdown.put("convertedStoredPath", quoteStorageService.toStoredPath(convertedPersistentPath));
}
item.setPricingBreakdown(breakdown);
item.setBoundingBoxXMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.xMm()))
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
item.setBoundingBoxYMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.yMm()))
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
item.setBoundingBoxZMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.zMm()))
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
return item;
}
}

View File

@@ -0,0 +1,77 @@
package com.printcalculator.service.quote;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.service.QuoteSessionTotalsService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class QuoteSessionResponseAssembler {
private final QuoteStorageService quoteStorageService;
public QuoteSessionResponseAssembler(QuoteStorageService quoteStorageService) {
this.quoteStorageService = quoteStorageService;
}
public Map<String, Object> assemble(QuoteSession session,
List<QuoteLineItem> items,
QuoteSessionTotalsService.QuoteSessionTotals totals) {
List<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
itemsDto.add(toItemDto(item, totals));
}
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", itemsDto);
response.put("printItemsTotalChf", totals.printItemsTotalChf());
response.put("cadTotalChf", totals.cadTotalChf());
response.put("itemsTotalChf", totals.itemsTotalChf());
response.put("shippingCostChf", totals.shippingCostChf());
response.put("globalMachineCostChf", totals.globalMachineCostChf());
response.put("grandTotalChf", totals.grandTotalChf());
return response;
}
private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
Map<String, Object> dto = new HashMap<>();
dto.put("id", item.getId());
dto.put("originalFilename", item.getOriginalFilename());
dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("materialCode", item.getMaterialCode());
dto.put("quality", item.getQuality());
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
dto.put("layerHeightMm", item.getLayerHeightMm());
dto.put("infillPercent", item.getInfillPercent());
dto.put("infillPattern", item.getInfillPattern());
dto.put("supportsEnabled", item.getSupportsEnabled());
dto.put("status", item.getStatus());
dto.put("convertedStoredPath", quoteStorageService.extractConvertedStoredPath(item));
dto.put("unitPriceChf", resolveDistributedUnitPrice(item, totals));
return dto;
}
private BigDecimal resolveDistributedUnitPrice(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1;
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
unitPrice = unitPrice.add(unitMachineCost);
}
return unitPrice;
}
}

View File

@@ -0,0 +1,179 @@
package com.printcalculator.service.quote;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Optional;
@Service
public class QuoteSessionSettingsService {
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public QuoteSessionSettingsService(PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
}
public void applyPrintSettings(PrintSettingsDto settings) {
if (settings.getNozzleDiameter() == null) {
settings.setNozzleDiameter(0.40);
}
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
switch (quality) {
case "draft" -> {
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
case "extra_fine", "high_definition", "high" -> {
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
}
case "standard" -> {
settings.setLayerHeight(0.20);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
default -> {
settings.setLayerHeight(0.20);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
}
} else {
if (settings.getInfillDensity() == null) {
settings.setInfillDensity(20.0);
}
if (settings.getInfillPattern() == null) {
settings.setInfillPattern("grid");
}
}
}
public void enforceCadPrintSettings(QuoteSession session, PrintSettingsDto settings) {
settings.setComplexityMode("ADVANCED");
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
}
public NozzleLayerSettings resolveNozzleAndLayer(PrintSettingsDto settings) {
BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle(
settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null
);
BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer(
settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null,
nozzleDiameter
);
if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter)
);
}
settings.setNozzleDiameter(nozzleDiameter.doubleValue());
settings.setLayerHeight(layerHeight.doubleValue());
return new NozzleLayerSettings(nozzleDiameter, layerHeight);
}
public PrinterMachine resolvePrinterMachine(Long printerMachineId) {
if (printerMachineId != null) {
PrinterMachine selected = machineRepo.findById(printerMachineId)
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId));
if (!Boolean.TRUE.equals(selected.getIsActive())) {
throw new RuntimeException("Selected printer machine is not active");
}
return selected;
}
return machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
public FilamentVariant resolveFilamentVariant(PrintSettingsDto settings) {
if (settings.getFilamentVariantId() != null) {
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
.orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId()));
if (!Boolean.TRUE.equals(variant.getIsActive())) {
throw new RuntimeException("Selected filament variant is not active");
}
return variant;
}
String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial());
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode)
.orElseGet(() -> materialRepo.findByMaterialCode("PLA")
.orElseThrow(() -> new RuntimeException("Fallback material PLA not configured")));
String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null;
if (requestedColor != null && !requestedColor.isBlank()) {
Optional<FilamentVariant> byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor);
if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) {
return byColor.get();
}
}
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
}
public String normalizeRequestedMaterialCode(String value) {
if (value == null || value.isBlank()) {
return "PLA";
}
return value.trim()
.toUpperCase(Locale.ROOT)
.replace('_', ' ')
.replace('-', ' ')
.replaceAll("\\s+", " ");
}
public String resolveQuality(PrintSettingsDto settings, BigDecimal layerHeight) {
if (settings.getQuality() != null && !settings.getQuality().isBlank()) {
return settings.getQuality().trim().toLowerCase(Locale.ROOT);
}
if (layerHeight == null) {
return "standard";
}
if (layerHeight.compareTo(BigDecimal.valueOf(0.24)) >= 0) {
return "draft";
}
if (layerHeight.compareTo(BigDecimal.valueOf(0.12)) <= 0) {
return "extra_fine";
}
return "standard";
}
public record NozzleLayerSettings(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
}
}

View File

@@ -0,0 +1,91 @@
package com.printcalculator.service.quote;
import com.printcalculator.entity.QuoteLineItem;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Service
public class QuoteStorageService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
public Path sessionStorageDir(UUID sessionId) throws IOException {
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(sessionId.toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
}
Files.createDirectories(sessionStorageDir);
return sessionStorageDir;
}
public Path resolveSessionPath(Path sessionStorageDir, String filename) throws IOException {
Path resolved = sessionStorageDir.resolve(filename).normalize();
if (!resolved.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path");
}
return resolved;
}
public String toStoredPath(Path absolutePath) {
return QUOTE_STORAGE_ROOT.relativize(absolutePath).toString();
}
public String getSafeExtension(String filename, String fallback) {
if (filename == null) {
return fallback;
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return fallback;
}
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) {
return fallback;
}
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}
public Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
public String extractConvertedStoredPath(QuoteLineItem item) {
Map<String, Object> breakdown = item.getPricingBreakdown();
if (breakdown == null) {
return null;
}
Object converted = breakdown.get("convertedStoredPath");
if (converted == null) {
return null;
}
String path = String.valueOf(converted).trim();
return path.isEmpty() ? null : path;
}
}

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import com.printcalculator.exception.VirusDetectedException; import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -7,7 +7,6 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.printcalculator.exception.StorageException; import com.printcalculator.exception.StorageException;
import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;

View File

@@ -27,7 +27,7 @@ clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration # TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:} payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
# Mail Configuration # Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com} spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
@@ -43,7 +43,7 @@ app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true} app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true}
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch} app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Order Shipped</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.status-box {
background-color: #e9f3ff;
border: 1px solid #a9c8ef;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box {
background-color: #f9f9f9;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box th {
text-align: left;
padding-right: 18px;
vertical-align: top;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">Good news: your package is on its way.</p>
<div class="status-box">
<strong th:text="${statusText}">Current status: Shipped.</strong>
</div>
<div class="order-box">
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p>
<span th:text="${orderDetailsCtaText}">View order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${supportText}">If you need assistance, reply to this email.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,140 @@
package com.printcalculator.controller;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.entity.Order;
import com.printcalculator.repository.CustomerRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.payment.TwintPaymentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OrderControllerPrivacyTest {
@Mock
private OrderService orderService;
@Mock
private OrderRepository orderRepo;
@Mock
private OrderItemRepository orderItemRepo;
@Mock
private QuoteSessionRepository quoteSessionRepo;
@Mock
private QuoteLineItemRepository quoteLineItemRepo;
@Mock
private CustomerRepository customerRepo;
@Mock
private StorageService storageService;
@Mock
private InvoicePdfRenderingService invoiceService;
@Mock
private QrBillService qrBillService;
@Mock
private TwintPaymentService twintPaymentService;
@Mock
private PaymentService paymentService;
@Mock
private PaymentRepository paymentRepo;
private OrderController controller;
@BeforeEach
void setUp() {
controller = new OrderController(
orderService,
orderRepo,
orderItemRepo,
quoteSessionRepo,
quoteLineItemRepo,
customerRepo,
storageService,
invoiceService,
qrBillService,
twintPaymentService,
paymentService,
paymentRepo
);
}
@Test
void getOrder_pendingPayment_keepsPersonalData() {
UUID orderId = UUID.randomUUID();
Order order = buildOrder(orderId, "PENDING_PAYMENT");
when(orderRepo.findById(orderId)).thenReturn(Optional.of(order));
when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of());
when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty());
ResponseEntity<OrderDto> response = controller.getOrder(orderId);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("customer@example.com", response.getBody().getCustomerEmail());
assertEquals("+41790000000", response.getBody().getCustomerPhone());
assertNotNull(response.getBody().getBillingAddress());
}
@Test
void getOrder_advancedStatuses_redactsPersonalData() {
List<String> statuses = List.of("IN_PRODUCTION", "SHIPPED", "COMPLETED");
for (String status : statuses) {
UUID orderId = UUID.randomUUID();
Order order = buildOrder(orderId, status);
when(orderRepo.findById(orderId)).thenReturn(Optional.of(order));
when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of());
when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty());
ResponseEntity<OrderDto> response = controller.getOrder(orderId);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertNull(response.getBody().getCustomerEmail());
assertNull(response.getBody().getCustomerPhone());
assertNull(response.getBody().getBillingCustomerType());
assertNull(response.getBody().getBillingAddress());
assertNull(response.getBody().getShippingAddress());
}
}
private Order buildOrder(UUID orderId, String status) {
Order order = new Order();
order.setId(orderId);
order.setStatus(status);
order.setCustomerEmail("customer@example.com");
order.setCustomerPhone("+41790000000");
order.setBillingCustomerType("PRIVATE");
order.setBillingFirstName("Joe");
order.setBillingLastName("Kung");
order.setBillingAddressLine1("Via G. Pioda 1");
order.setBillingZip("6900");
order.setBillingCity("Lugano");
order.setBillingCountryCode("CH");
order.setShippingSameAsBilling(true);
return order;
}
}

View File

@@ -6,15 +6,16 @@ import com.printcalculator.entity.Order;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -47,6 +48,8 @@ class AdminOrderControllerStatusValidationTest {
private InvoicePdfRenderingService invoicePdfRenderingService; private InvoicePdfRenderingService invoicePdfRenderingService;
@Mock @Mock
private QrBillService qrBillService; private QrBillService qrBillService;
@Mock
private ApplicationEventPublisher eventPublisher;
private AdminOrderController controller; private AdminOrderController controller;
@@ -59,7 +62,8 @@ class AdminOrderControllerStatusValidationTest {
paymentService, paymentService,
storageService, storageService,
invoicePdfRenderingService, invoicePdfRenderingService,
qrBillService qrBillService,
eventPublisher
); );
} }
@@ -92,6 +96,7 @@ class AdminOrderControllerStatusValidationTest {
order.setStatus("PENDING_PAYMENT"); order.setStatus("PENDING_PAYMENT");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of()); when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of());
when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty()); when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty());

View File

@@ -4,9 +4,9 @@ import com.printcalculator.entity.Customer;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

24
db.sql
View File

@@ -660,6 +660,12 @@ CREATE TABLE IF NOT EXISTS quote_line_items
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
color_code text, -- es: white/black o codice interno color_code text, -- es: white/black o codice interno
filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), filament_variant_id bigint REFERENCES filament_variant (filament_variant_id),
material_code text,
nozzle_diameter_mm numeric(5, 2),
layer_height_mm numeric(6, 3),
infill_pattern text,
infill_percent integer CHECK (infill_percent BETWEEN 0 AND 100),
supports_enabled boolean,
-- Output slicing / calcolo -- Output slicing / calcolo
bounding_box_x_mm numeric(10, 3), bounding_box_x_mm numeric(10, 3),
@@ -680,6 +686,24 @@ CREATE TABLE IF NOT EXISTS quote_line_items
CREATE INDEX IF NOT EXISTS ix_quote_line_items_session CREATE INDEX IF NOT EXISTS ix_quote_line_items_session
ON quote_line_items (quote_session_id); ON quote_line_items (quote_session_id);
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS material_code text;
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS nozzle_diameter_mm numeric(5, 2);
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS layer_height_mm numeric(6, 3);
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS infill_pattern text;
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS infill_percent integer;
ALTER TABLE quote_line_items
ADD COLUMN IF NOT EXISTS supports_enabled boolean;
-- Vista utile per totale quote -- Vista utile per totale quote
CREATE OR REPLACE VIEW quote_session_totals AS CREATE OR REPLACE VIEW quote_session_totals AS
SELECT qs.quote_session_id, SELECT qs.quote_session_id,

View File

@@ -13,6 +13,7 @@ services:
- CLAMAV_HOST=${CLAMAV_HOST} - CLAMAV_HOST=${CLAMAV_HOST}
- CLAMAV_PORT=${CLAMAV_PORT} - CLAMAV_PORT=${CLAMAV_PORT}
- CLAMAV_ENABLED=${CLAMAV_ENABLED} - CLAMAV_ENABLED=${CLAMAV_ENABLED}
- TWINT_PAYMENT_URL=${TWINT_PAYMENT_URL:-}
- MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com} - MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com}
- MAIL_PORT=${MAIL_PORT:-587} - MAIL_PORT=${MAIL_PORT:-587}
- MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch} - MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch}

View File

@@ -0,0 +1,21 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /admin/
Disallow: /*/admin
Disallow: /*/admin/
Disallow: /order/
Disallow: /*/order/
Disallow: /co/
Disallow: /*/co/
Disallow: /checkout
Disallow: /checkout/
Disallow: /*/checkout
Disallow: /*/checkout/
Disallow: /shop
Disallow: /shop/
Disallow: /*/shop
Disallow: /*/shop/
Sitemap: https://3d-fab.ch/sitemap.xml

144
frontend/public/sitemap.xml Normal file
View File

@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/basic"
/>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/about"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/contact"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/contact"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/contact"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/contact"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/contact"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/privacy"
/>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SeoService } from './core/services/seo.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -8,4 +9,6 @@ import { RouterOutlet } from '@angular/router';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent {} export class AppComponent {
private readonly seoService = inject(SeoService);
}

View File

@@ -5,6 +5,11 @@ const appChildRoutes: Routes = [
path: '', path: '',
loadComponent: () => loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent), import('./features/home/home.component').then((m) => m.HomeComponent),
data: {
seoTitle: '3D fab | Stampa 3D su misura',
seoDescription:
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
},
}, },
{ {
path: 'calculator', path: 'calculator',
@@ -12,21 +17,42 @@ const appChildRoutes: Routes = [
import('./features/calculator/calculator.routes').then( import('./features/calculator/calculator.routes').then(
(m) => m.CALCULATOR_ROUTES, (m) => m.CALCULATOR_ROUTES,
), ),
data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
seoDescription:
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
},
}, },
{ {
path: 'shop', path: 'shop',
loadChildren: () => loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES), import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'about', path: 'about',
loadChildren: () => loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES), import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
},
}, },
{ {
path: 'contact', path: 'contact',
loadChildren: () => loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
},
}, },
{ {
path: 'checkout/cad', path: 'checkout/cad',
@@ -34,6 +60,10 @@ const appChildRoutes: Routes = [
import('./features/checkout/checkout.component').then( import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: {
seoTitle: 'Checkout | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'checkout', path: 'checkout',
@@ -41,16 +71,28 @@ const appChildRoutes: Routes = [
import('./features/checkout/checkout.component').then( import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: {
seoTitle: 'Checkout | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'order/:orderId', path: 'order/:orderId',
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: 'co/:orderId', path: 'co/:orderId',
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: '', path: '',
@@ -61,6 +103,10 @@ const appChildRoutes: Routes = [
path: 'admin', path: 'admin',
loadChildren: () => loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES), import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: {
seoTitle: 'Admin | 3D fab',
seoRobots: 'noindex, nofollow',
},
}, },
{ {
path: '**', path: '**',

View File

@@ -0,0 +1,129 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class SeoService {
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
private readonly defaultDescription =
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
constructor(
private router: Router,
private titleService: Title,
private metaService: Meta,
@Inject(DOCUMENT) private document: Document,
) {
this.applyRouteSeo(this.router.routerState.snapshot.root);
this.router.events
.pipe(
filter(
(event): event is NavigationEnd => event instanceof NavigationEnd,
),
)
.subscribe(() => {
this.applyRouteSeo(this.router.routerState.snapshot.root);
});
}
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot);
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
const description =
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description });
this.metaService.updateTag({ name: 'robots', content: robots });
this.metaService.updateTag({ property: 'og:title', content: title });
this.metaService.updateTag({
property: 'og:description',
content: description,
});
this.metaService.updateTag({ property: 'og:type', content: 'website' });
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
const cleanPath = this.getCleanPath(this.router.url);
const canonical = `${this.document.location.origin}${cleanPath}`;
this.metaService.updateTag({ property: 'og:url', content: canonical });
this.updateCanonicalTag(canonical);
this.updateLangAndAlternates(cleanPath);
}
private getMergedRouteData(
snapshot: ActivatedRouteSnapshot,
): Record<string, unknown> {
const merged: Record<string, unknown> = {};
let cursor: ActivatedRouteSnapshot | null = snapshot;
while (cursor) {
Object.assign(merged, cursor.data ?? {});
cursor = cursor.firstChild;
}
return merged;
}
private asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
}
private updateCanonicalTag(url: string): void {
let link = this.document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase();
const hasLang = Boolean(
firstSegment && this.supportedLangs.has(firstSegment),
);
const lang = hasLang ? firstSegment : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
this.document.documentElement.lang = lang;
this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) {
this.appendAlternateLink(
alt,
`${this.document.location.origin}/${alt}${suffix}`,
);
}
this.appendAlternateLink(
'x-default',
`${this.document.location.origin}/it${suffix}`,
);
}
private appendAlternateLink(hreflang: string, href: string): void {
const link = this.document.createElement('link');
link.setAttribute('rel', 'alternate');
link.setAttribute('hreflang', hreflang);
link.setAttribute('href', href);
link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link);
}
}

View File

@@ -2,5 +2,13 @@ import { Routes } from '@angular/router';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
export const ABOUT_ROUTES: Routes = [ export const ABOUT_ROUTES: Routes = [
{ path: '', component: AboutPageComponent }, {
path: '',
component: AboutPageComponent,
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
},
},
]; ];

View File

@@ -163,12 +163,12 @@
</select> </select>
<button <button
type="button" type="button"
(click)="confirmPayment()" (click)="updatePaymentMethod()"
[disabled]=" [disabled]="confirmingPayment"
confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'
"
> >
{{ confirmingPayment ? "Invio..." : "Conferma pagamento" }} {{
confirmingPayment ? "Salvataggio..." : "Cambia metodo pagamento"
}}
</button> </button>
</div> </div>
</div> </div>
@@ -192,13 +192,18 @@
<strong>{{ item.originalFilename }}</strong> <strong>{{ item.originalFilename }}</strong>
</p> </p>
<p class="item-meta"> <p class="item-meta">
Qta: {{ item.quantity }} | Colore: Qta: {{ item.quantity }} | Materiale:
{{ item.materialCode || "-" }} | Colore:
<span <span
class="color-swatch" class="color-swatch"
*ngIf="isHexColor(item.colorCode)" *ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode" [style.background-color]="item.colorCode"
></span> ></span>
<span>{{ item.colorCode || "-" }}</span> <span>{{ item.colorCode || "-" }}</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti:
{{ item.supportsEnabled ? "Sì" : "No" }}
| Riga: | Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }} {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p> </p>
@@ -273,17 +278,15 @@
</div> </div>
</div> </div>
<h4>Colori file</h4> <h4>Parametri per file</h4>
<div class="file-color-list"> <div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items"> <div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span> <span class="filename">{{ item.originalFilename }}</span>
<span class="file-color"> <span class="file-color">
<span {{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm
class="color-swatch" | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}%
*ngIf="isHexColor(item.colorCode)" | {{ item.infillPattern || "-" }} |
[style.background-color]="item.colorCode" {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
></span>
{{ item.colorCode || "-" }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -132,14 +132,14 @@ export class AdminDashboardComponent implements OnInit {
}); });
} }
confirmPayment(): void { updatePaymentMethod(): void {
if (!this.selectedOrder || this.confirmingPayment) { if (!this.selectedOrder || this.confirmingPayment) {
return; return;
} }
this.confirmingPayment = true; this.confirmingPayment = true;
this.adminOrdersService this.adminOrdersService
.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod) .updatePaymentMethod(this.selectedOrder.id, this.selectedPaymentMethod)
.subscribe({ .subscribe({
next: (updatedOrder) => { next: (updatedOrder) => {
this.confirmingPayment = false; this.confirmingPayment = false;
@@ -147,7 +147,7 @@ export class AdminDashboardComponent implements OnInit {
}, },
error: () => { error: () => {
this.confirmingPayment = false; this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.'; this.errorMessage = 'Aggiornamento metodo pagamento non riuscito.';
}, },
}); });
} }

View File

@@ -126,6 +126,7 @@
<th>Qta</th> <th>Qta</th>
<th>Tempo</th> <th>Tempo</th>
<th>Materiale</th> <th>Materiale</th>
<th>Scelte utente</th>
<th>Stato</th> <th>Stato</th>
<th>Prezzo unit.</th> <th>Prezzo unit.</th>
</tr> </tr>
@@ -142,6 +143,14 @@
: "-" : "-"
}} }}
</td> </td>
<td>
{{ item.materialCode || "-" }} |
{{ item.nozzleDiameterMm ?? "-" }} mm |
{{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% |
{{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
</td>
<td>{{ item.status }}</td> <td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency: "CHF" }}</td> <td>{{ item.unitPriceChf | currency: "CHF" }}</td>
</tr> </tr>

View File

@@ -127,7 +127,15 @@ export interface AdminQuoteSessionDetailItem {
quantity: number; quantity: number;
printTimeSeconds?: number; printTimeSeconds?: number;
materialGrams?: number; materialGrams?: number;
materialCode?: string;
quality?: string;
nozzleDiameterMm?: number;
layerHeightMm?: number;
infillPercent?: number;
infillPattern?: string;
supportsEnabled?: boolean;
colorCode?: string; colorCode?: string;
filamentVariantId?: number;
status: string; status: string;
unitPriceChf: number; unitPriceChf: number;
} }

View File

@@ -8,6 +8,12 @@ export interface AdminOrderItem {
originalFilename: string; originalFilename: string;
materialCode: string; materialCode: string;
colorCode: string; colorCode: string;
quality?: string;
nozzleDiameterMm?: number;
layerHeightMm?: number;
infillPercent?: number;
infillPattern?: string;
supportsEnabled?: boolean;
quantity: number; quantity: number;
printTimeSeconds: number; printTimeSeconds: number;
materialGrams: number; materialGrams: number;
@@ -59,7 +65,7 @@ export class AdminOrdersService {
}); });
} }
confirmPayment(orderId: string, method: string): Observable<AdminOrder> { updatePaymentMethod(orderId: string, method: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>( return this.http.post<AdminOrder>(
`${this.baseUrl}/${orderId}/payments/confirm`, `${this.baseUrl}/${orderId}/payments/confirm`,
{ method }, { method },

View File

@@ -24,7 +24,7 @@
class="mode-option" class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
[class.disabled]="cadSessionLocked()" [class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('easy')" (click)="switchMode('easy')"
> >
{{ "CALC.MODE_EASY" | translate }} {{ "CALC.MODE_EASY" | translate }}
</div> </div>
@@ -32,7 +32,7 @@
class="mode-option" class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
[class.disabled]="cadSessionLocked()" [class.disabled]="cadSessionLocked()"
(click)="!cadSessionLocked() && mode.set('advanced')" (click)="switchMode('advanced')"
> >
{{ "CALC.MODE_ADVANCED" | translate }} {{ "CALC.MODE_ADVANCED" | translate }}
</div> </div>
@@ -45,6 +45,9 @@
[loading]="loading()" [loading]="loading()"
[uploadProgress]="uploadProgress()" [uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
(itemQuantityChange)="onUploadItemQuantityChange($event)"
(printSettingsChange)="onUploadPrintSettingsChange($event)"
(itemSettingsDiffChange)="onItemSettingsDiffChange($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
</div> </div>
@@ -64,8 +67,11 @@
} @else if (result()) { } @else if (result()) {
<app-quote-result <app-quote-result
[result]="result()!" [result]="result()!"
[recalculationRequired]="requiresRecalculation()"
[itemSettingsDiffByFileName]="itemSettingsDiffByFileName()"
(consult)="onConsult()" (consult)="onConsult()"
(proceed)="onProceed()" (proceed)="onProceed()"
(itemQuantityPreviewChange)="onQuoteItemQuantityPreviewChange($event)"
(itemChange)="onItemChange($event)" (itemChange)="onItemChange($event)"
></app-quote-result> ></app-quote-result>
} @else if (isZeroQuoteError()) { } @else if (isZeroQuoteError()) {

View File

@@ -25,6 +25,17 @@ import { SuccessStateComponent } from '../../shared/components/success-state/suc
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
type TrackedPrintSettings = {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
};
@Component({ @Component({
selector: 'app-calculator-page', selector: 'app-calculator-page',
standalone: true, standalone: true,
@@ -42,7 +53,7 @@ import { LanguageService } from '../../core/services/language.service';
styleUrl: './calculator-page.component.scss', styleUrl: './calculator-page.component.scss',
}) })
export class CalculatorPageComponent implements OnInit { export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy'); mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false); loading = signal(false);
@@ -56,6 +67,12 @@ export class CalculatorPageComponent implements OnInit {
); );
orderSuccess = signal(false); orderSuccess = signal(false);
requiresRecalculation = signal(false);
itemSettingsDiffByFileName = signal<
Record<string, { differences: string[] }>
>({});
private baselinePrintSettings: TrackedPrintSettings | null = null;
private baselineItemSettingsByFileName = new Map<string, TrackedPrintSettings>();
@ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef; @ViewChild('resultCol') resultCol!: ElementRef;
@@ -101,6 +118,15 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false); this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC'); this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result); this.result.set(result);
this.baselinePrintSettings = this.toTrackedSettingsFromSession(
data.session,
);
this.baselineItemSettingsByFileName = this.buildBaselineMapFromSession(
data.items || [],
this.baselinePrintSettings,
);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
const isCadSession = data?.session?.status === 'CAD_ACTIVE'; const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession); this.cadSessionLocked.set(isCadSession);
this.step.set('quote'); this.step.set('quote');
@@ -173,14 +199,21 @@ export class CalculatorPageComponent implements OnInit {
}); });
this.uploadForm.patchSettings(session); this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => { items.forEach((item, index) => {
// Assuming index matches. const tracked = this.toTrackedSettingsFromSessionItem(
// Need to be careful if items order changed, but usually ID sort or insert order. item,
this.toTrackedSettingsFromSession(session),
);
this.uploadForm.setItemPrintSettingsByIndex(index, {
material: tracked.material.toUpperCase(),
quality: tracked.quality,
nozzleDiameter: tracked.nozzleDiameter,
layerHeight: tracked.layerHeight,
infillDensity: tracked.infillDensity,
infillPattern: tracked.infillPattern,
supportEnabled: tracked.supportEnabled,
});
if (item.colorCode) { if (item.colorCode) {
this.uploadForm.updateItemColor(index, { this.uploadForm.updateItemColor(index, {
colorName: item.colorCode, colorName: item.colorCode,
@@ -188,8 +221,11 @@ export class CalculatorPageComponent implements OnInit {
}); });
} }
}); });
const selected = this.uploadForm.selectedFile();
if (selected) {
this.uploadForm.selectFile(selected);
} }
});
} }
this.loading.set(false); this.loading.set(false);
}, },
@@ -238,6 +274,11 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false); this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC'); this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res); this.result.set(res);
this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req);
this.baselineItemSettingsByFileName =
this.buildBaselineMapFromRequest(req);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.loading.set(false); this.loading.set(false);
this.uploadProgress.set(100); this.uploadProgress.set(100);
this.step.set('quote'); this.step.set('quote');
@@ -295,9 +336,10 @@ export class CalculatorPageComponent implements OnInit {
index: number; index: number;
fileName: string; fileName: string;
quantity: number; quantity: number;
source?: 'left' | 'right';
}) { }) {
// 1. Update local form for consistency (UI feedback) // 1. Update local form for consistency (UI feedback)
if (this.uploadForm) { if (event.source !== 'left' && this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
} }
@@ -340,6 +382,33 @@ export class CalculatorPageComponent implements OnInit {
} }
} }
onUploadItemQuantityChange(event: {
index: number;
fileName: string;
quantity: number;
}) {
const resultItems = this.result()?.items || [];
const byIndex = resultItems[event.index];
const byName = resultItems.find((item) => item.fileName === event.fileName);
const id = byIndex?.id ?? byName?.id;
this.onItemChange({
...event,
id,
source: 'left',
});
}
onQuoteItemQuantityPreviewChange(event: {
index: number;
fileName: string;
quantity: number;
}) {
if (!this.uploadForm) return;
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
onSubmitOrder(orderData: any) { onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData); console.log('Order Submitted:', orderData);
this.orderSuccess.set(true); this.orderSuccess.set(true);
@@ -349,15 +418,37 @@ export class CalculatorPageComponent implements OnInit {
onNewQuote() { onNewQuote() {
this.step.set('upload'); this.step.set('upload');
this.result.set(null); this.result.set(null);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.baselinePrintSettings = null;
this.baselineItemSettingsByFileName = new Map<
string,
TrackedPrintSettings
>();
this.cadSessionLocked.set(false); this.cadSessionLocked.set(false);
this.orderSuccess.set(false); this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default this.switchMode('easy'); // Reset to default and sync URL
} }
private currentRequest: QuoteRequest | null = null; private currentRequest: QuoteRequest | null = null;
onUploadPrintSettingsChange(_: TrackedPrintSettings) {
void _;
if (!this.result()) return;
this.refreshRecalculationRequirement();
}
onItemSettingsDiffChange(
diffByFileName: Record<string, { differences: string[] }>,
) {
this.itemSettingsDiffByFileName.set(diffByFileName || {});
}
onConsult() { onConsult() {
if (!this.currentRequest) { const currentFormRequest = this.uploadForm?.getCurrentRequestDraft();
const req = currentFormRequest ?? this.currentRequest;
if (!req) {
this.router.navigate([ this.router.navigate([
'/', '/',
this.languageService.selectedLang(), this.languageService.selectedLang(),
@@ -366,7 +457,6 @@ export class CalculatorPageComponent implements OnInit {
return; return;
} }
const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`; let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`; details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`; details += `- Qualità: ${req.quality}\n`;
@@ -411,5 +501,231 @@ export class CalculatorPageComponent implements OnInit {
this.errorKey.set(key); this.errorKey.set(key);
this.error.set(true); this.error.set(true);
this.result.set(null); this.result.set(null);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.baselinePrintSettings = null;
this.baselineItemSettingsByFileName = new Map<
string,
TrackedPrintSettings
>();
}
switchMode(nextMode: 'easy' | 'advanced'): void {
if (this.cadSessionLocked()) return;
const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
const currentPath = this.route.snapshot.routeConfig?.path;
this.mode.set(nextMode);
if (currentPath === targetPath) {
return;
}
this.router.navigate(['..', targetPath], {
relativeTo: this.route,
queryParamsHandling: 'preserve',
});
}
private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings {
return {
mode: req.mode,
material: this.normalizeString(req.material || 'PLA'),
quality: this.normalizeString(req.quality || 'standard'),
nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2),
layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3),
infillDensity: this.normalizeNumber(req.infillDensity, 20, 2),
infillPattern: this.normalizeString(req.infillPattern || 'grid'),
supportEnabled: Boolean(req.supportEnabled),
};
}
private toTrackedSettingsFromItem(
req: QuoteRequest,
item: QuoteRequest['items'][number],
): TrackedPrintSettings {
return {
mode: req.mode,
material: this.normalizeString(item.material || req.material || 'PLA'),
quality: this.normalizeString(item.quality || req.quality || 'standard'),
nozzleDiameter: this.normalizeNumber(
item.nozzleDiameter ?? req.nozzleDiameter,
0.4,
2,
),
layerHeight: this.normalizeNumber(
item.layerHeight ?? req.layerHeight,
0.2,
3,
),
infillDensity: this.normalizeNumber(
item.infillDensity ?? req.infillDensity,
20,
2,
),
infillPattern: this.normalizeString(
item.infillPattern || req.infillPattern || 'grid',
),
supportEnabled: Boolean(item.supportEnabled ?? req.supportEnabled),
};
}
private toTrackedSettingsFromSession(session: any): TrackedPrintSettings {
const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3);
return {
mode: this.mode(),
material: this.normalizeString(session?.materialCode || 'PLA'),
quality:
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard',
nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2),
layerHeight: layer,
infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2),
infillPattern: this.normalizeString(session?.infillPattern || 'grid'),
supportEnabled: Boolean(session?.supportsEnabled),
};
}
private toTrackedSettingsFromSessionItem(
item: any,
fallback: TrackedPrintSettings,
): TrackedPrintSettings {
const layer = this.normalizeNumber(item?.layerHeightMm, fallback.layerHeight, 3);
return {
mode: this.mode(),
material: this.normalizeString(item?.materialCode || fallback.material),
quality: this.normalizeString(
item?.quality ||
(layer >= 0.24
? 'draft'
: layer <= 0.12
? 'extra_fine'
: 'standard'),
),
nozzleDiameter: this.normalizeNumber(
item?.nozzleDiameterMm,
fallback.nozzleDiameter,
2,
),
layerHeight: layer,
infillDensity: this.normalizeNumber(
item?.infillPercent,
fallback.infillDensity,
2,
),
infillPattern: this.normalizeString(
item?.infillPattern || fallback.infillPattern,
),
supportEnabled: Boolean(
item?.supportsEnabled ?? fallback.supportEnabled,
),
};
}
private buildBaselineMapFromRequest(
req: QuoteRequest,
): Map<string, TrackedPrintSettings> {
const map = new Map<string, TrackedPrintSettings>();
req.items.forEach((item) => {
map.set(
this.normalizeFileName(item.file?.name || ''),
this.toTrackedSettingsFromItem(req, item),
);
});
return map;
}
private buildBaselineMapFromSession(
items: any[],
defaultSettings: TrackedPrintSettings | null,
): Map<string, TrackedPrintSettings> {
const map = new Map<string, TrackedPrintSettings>();
const fallback = defaultSettings ?? this.defaultTrackedSettings();
items.forEach((item) => {
map.set(
this.normalizeFileName(item?.originalFilename || ''),
this.toTrackedSettingsFromSessionItem(item, fallback),
);
});
return map;
}
private defaultTrackedSettings(): TrackedPrintSettings {
return {
mode: this.mode(),
material: 'pla',
quality: 'standard',
nozzleDiameter: 0.4,
layerHeight: 0.2,
infillDensity: 20,
infillPattern: 'grid',
supportEnabled: false,
};
}
private refreshRecalculationRequirement(): void {
if (!this.result()) return;
const draft = this.uploadForm?.getCurrentRequestDraft();
if (!draft || draft.items.length === 0) {
this.requiresRecalculation.set(false);
return;
}
const fallback = this.baselinePrintSettings;
if (!fallback) {
this.requiresRecalculation.set(false);
return;
}
const changed = draft.items.some((item) => {
const key = this.normalizeFileName(item.file?.name || '');
const baseline = this.baselineItemSettingsByFileName.get(key) || fallback;
const current = this.toTrackedSettingsFromItem(draft, item);
return !this.sameTrackedSettings(baseline, current);
});
this.requiresRecalculation.set(changed);
}
private sameTrackedSettings(
a: TrackedPrintSettings,
b: TrackedPrintSettings,
): boolean {
return (
a.mode === b.mode &&
a.material === this.normalizeString(b.material) &&
a.quality === this.normalizeString(b.quality) &&
Math.abs(
a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2),
) < 0.0001 &&
Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) <
0.0001 &&
Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) <
0.0001 &&
a.infillPattern === this.normalizeString(b.infillPattern) &&
a.supportEnabled === Boolean(b.supportEnabled)
);
}
private normalizeFileName(fileName: string): string {
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
private normalizeString(value: string): string {
return String(value || '')
.trim()
.toLowerCase();
}
private normalizeNumber(
value: unknown,
fallback: number,
decimals: number,
): number {
const numeric = Number(value);
const resolved = Number.isFinite(numeric) ? numeric : fallback;
const factor = 10 ** decimals;
return Math.round(resolved * factor) / factor;
} }
} }

View File

@@ -3,10 +3,24 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [ export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' }, { path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } }, {
path: 'basic',
component: CalculatorPageComponent,
data: {
mode: 'easy',
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
seoDescription:
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
},
},
{ {
path: 'advanced', path: 'advanced',
component: CalculatorPageComponent, component: CalculatorPageComponent,
data: { mode: 'advanced' }, data: {
mode: 'advanced',
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
seoDescription:
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
},
}, },
]; ];

View File

@@ -45,6 +45,12 @@
<p>{{ result().notes }}</p> <p>{{ result().notes }}</p>
</div> </div>
} }
@if (recalculationRequired()) {
<div class="recalc-banner">
Hai modificato i parametri di stampa. Ricalcola il preventivo prima di
procedere con l'ordine.
</div>
}
<div class="divider"></div> <div class="divider"></div>
@@ -56,7 +62,14 @@
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
<span class="file-details"> <span class="file-details">
{{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g {{ item.unitWeight | number: "1.0-0" }}g |
materiale: {{ item.material || "N/D" }}
@if (getItemDifferenceLabel(item.fileName)) {
|
<small class="item-settings-diff">
{{ getItemDifferenceLabel(item.fileName) }}
</small>
}
</span> </span>
</div> </div>
@@ -96,12 +109,18 @@
</div> </div>
<div class="actions"> <div class="actions">
<div class="actions-left">
<app-button variant="outline" (click)="consult.emit()"> <app-button variant="outline" (click)="consult.emit()">
{{ "QUOTE.CONSULT" | translate }} {{ "QUOTE.CONSULT" | translate }}
</app-button> </app-button>
</div>
<div class="actions-right">
@if (!hasQuantityOverLimit()) { @if (!hasQuantityOverLimit()) {
<app-button (click)="proceed.emit()"> <app-button
[disabled]="recalculationRequired()"
(click)="proceed.emit()"
>
{{ "QUOTE.PROCEED_ORDER" | translate }} {{ "QUOTE.PROCEED_ORDER" | translate }}
</app-button> </app-button>
} @else { } @else {
@@ -109,5 +128,11 @@
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit } "QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}</small> }}</small>
} }
@if (recalculationRequired()) {
<small class="limit-note">
Ricalcola il preventivo per riattivare il checkout.
</small>
}
</div>
</div> </div>
</app-card> </app-card>

View File

@@ -41,6 +41,14 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.item-settings-diff {
margin-left: 2px;
font-size: 0.78rem;
font-weight: 600;
color: #8a6d1f;
white-space: normal;
}
.file-details { .file-details {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -126,15 +134,39 @@
.actions { .actions {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
align-items: center;
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-2);
@media (max-width: 640px) {
flex-direction: column;
align-items: stretch;
}
}
.actions-left,
.actions-right {
display: flex;
align-items: center;
}
.actions-right {
justify-content: flex-end;
@media (max-width: 640px) {
justify-content: flex-start;
}
} }
.limit-note { .limit-note {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: center; text-align: right;
margin-top: calc(var(--space-2) * -1);
@media (max-width: 640px) {
text-align: left;
}
} }
.notes-section { .notes-section {
@@ -160,3 +192,14 @@
white-space: pre-wrap; /* Preserve line breaks */ white-space: pre-wrap; /* Preserve line breaks */
} }
} }
.recalc-banner {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
border: 1px solid #f0c95a;
background: #fff8e1;
border-radius: var(--radius-md);
color: #6f5b1a;
font-size: 0.9rem;
}

View File

@@ -35,6 +35,10 @@ export class QuoteResultComponent implements OnDestroy {
readonly quantityAutoRefreshMs = 2000; readonly quantityAutoRefreshMs = 2000;
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
recalculationRequired = input<boolean>(false);
itemSettingsDiffByFileName = input<Record<string, { differences: string[] }>>(
{},
);
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
itemChange = output<{ itemChange = output<{
@@ -43,6 +47,12 @@ export class QuoteResultComponent implements OnDestroy {
fileName: string; fileName: string;
quantity: number; quantity: number;
}>(); }>();
itemQuantityPreviewChange = output<{
id?: string;
index: number;
fileName: string;
quantity: number;
}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
@@ -87,6 +97,13 @@ export class QuoteResultComponent implements OnDestroy {
return updated; return updated;
}); });
this.itemQuantityPreviewChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty,
});
this.scheduleQuantityRefresh(index, key); this.scheduleQuantityRefresh(index, key);
} }
@@ -171,4 +188,15 @@ export class QuoteResultComponent implements OnDestroy {
this.quantityTimers.forEach((timer) => clearTimeout(timer)); this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear(); this.quantityTimers.clear();
} }
getItemDifferenceLabel(fileName: string): string {
const differences =
this.itemSettingsDiffByFileName()[fileName]?.differences || [];
if (differences.length === 0) return '';
const materialOnly = differences.find(
(entry) => !entry.includes(':') && entry.trim().length > 0,
);
return materialOnly || differences.join(' | ');
}
} }

View File

@@ -63,7 +63,7 @@
<app-color-selector <app-color-selector
[selectedColor]="item.color" [selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null" [selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()" [variants]="getVariantsForMaterial(item.material)"
(colorSelected)="updateItemColor(i, $event)" (colorSelected)="updateItemColor(i, $event)"
> >
</app-color-selector> </app-color-selector>
@@ -102,11 +102,6 @@
+ {{ "CALC.ADD_FILES" | translate }} + {{ "CALC.ADD_FILES" | translate }}
</button> </button>
</div> </div>
}
@if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
}
<p class="upload-privacy-note"> <p class="upload-privacy-note">
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }} {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
@@ -115,21 +110,145 @@
}}</a }}</a
>. >.
</p> </p>
<label class="item-settings-checkbox item-settings-checkbox--top">
<input
type="checkbox"
[checked]="sameSettingsForAll()"
(change)="onSameSettingsToggle($any($event.target).checked)"
/>
<span>Tutti i file uguali (applica impostazioni a tutti)</span>
</label>
@if (sameSettingsForAll()) {
<div class="item-settings-panel">
<h4 class="item-settings-title">Impostazioni globali</h4>
<div class="item-settings-grid">
<label>
{{ "CALC.MATERIAL" | translate }}
<select formControlName="material">
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
@if (mode() === "easy") {
<label>
{{ "CALC.QUALITY" | translate }}
<select formControlName="quality">
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
} @else {
<label>
{{ "CALC.NOZZLE" | translate }}
<select formControlName="nozzleDiameter">
@for (n of nozzleDiameters(); track n.value) {
<option [value]="n.value">{{ n.label }}</option>
}
</select>
</label>
}
</div> </div>
<div class="grid"> @if (mode() === "advanced") {
@if (lockedSettings()) { <div class="item-settings-grid">
<p class="upload-privacy-note"> <label>
Parametri stampa bloccati per sessione CAD: materiale, nozzle, layer, {{ "CALC.PATTERN" | translate }}
infill e supporti sono definiti dal back-office. <select formControlName="infillPattern">
</p> @for (p of infillPatterns(); track p.value) {
<option [value]="p.value">{{ p.label }}</option>
} }
<app-select </select>
formControlName="material" </label>
[label]="'CALC.MATERIAL' | translate"
[options]="materials()"
></app-select>
<label>
{{ "CALC.LAYER_HEIGHT" | translate }}
<select formControlName="layerHeight">
@for (l of layerHeights(); track l.value) {
<option [value]="l.value">{{ l.label }}</option>
}
</select>
</label>
</div>
<div class="item-settings-grid">
<label>
{{ "CALC.INFILL" | translate }}
<input type="number" min="0" max="100" formControlName="infillDensity" />
</label>
<label class="item-settings-checkbox">
<input type="checkbox" formControlName="supportEnabled" />
<span>{{ "CALC.SUPPORT" | translate }}</span>
</label>
</div>
}
</div>
} @else {
@if (getSelectedItem(); as selectedItem) {
<div class="item-settings-panel">
<h4 class="item-settings-title">
Impostazioni file: {{ selectedItem.file.name }}
</h4>
<div class="item-settings-grid">
<label>
{{ "CALC.MATERIAL" | translate }}
<select
[value]="selectedItem.material || form.get('material')?.value"
(change)="
updateItemMaterial(getSelectedItemIndex(), $any($event.target).value)
"
>
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
@if (mode() === "easy") {
<label>
{{ "CALC.QUALITY" | translate }}
<select
[value]="selectedItem.quality || form.get('quality')?.value"
(change)="
updateSelectedItemStringField(
'quality',
$any($event.target).value
)
"
>
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
} @else {
<label>
{{ "CALC.NOZZLE" | translate }}
<select
[value]="
selectedItem.nozzleDiameter ?? form.get('nozzleDiameter')?.value
"
(change)="
updateSelectedItemNumberField(
'nozzleDiameter',
+$any($event.target).value
)
"
>
@for (n of nozzleDiameters(); track n.value) {
<option [value]="n.value">{{ n.label }}</option>
}
</select>
</label>
}
</div>
@if (mode() === "easy") { @if (mode() === "easy") {
<app-select <app-select
formControlName="quality" formControlName="quality"
@@ -145,37 +264,94 @@
} }
</div> </div>
<!-- Global quantity removed, now per item --> @if (items().length > 1) {
<div class="checkbox-row sync-all-row">
@if (mode() === "advanced") { <input type="checkbox" formControlName="syncAllItems" id="syncAllItems" />
<div class="grid"> <label for="syncAllItems">
<app-select Uguale per tutti i pezzi
formControlName="infillPattern" </label>
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
</div>
<div class="grid">
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support" />
<label for="support">{{ "CALC.SUPPORT" | translate }}</label>
</div>
</div> </div>
} }
@if (mode() === "advanced") {
<div class="item-settings-grid">
<label>
{{ "CALC.PATTERN" | translate }}
<select
[value]="selectedItem.infillPattern || form.get('infillPattern')?.value"
(change)="
updateSelectedItemStringField(
'infillPattern',
$any($event.target).value
)
"
>
@for (p of infillPatterns(); track p.value) {
<option [value]="p.value">{{ p.label }}</option>
}
</select>
</label>
<label>
{{ "CALC.LAYER_HEIGHT" | translate }}
<select
[value]="selectedItem.layerHeight ?? form.get('layerHeight')?.value"
(change)="
updateSelectedItemNumberField(
'layerHeight',
+$any($event.target).value
)
"
>
@for (l of layerHeights(); track l.value) {
<option [value]="l.value">{{ l.label }}</option>
}
</select>
</label>
</div>
<div class="item-settings-grid">
<label>
{{ "CALC.INFILL" | translate }}
<input
type="number"
min="0"
max="100"
[value]="
selectedItem.infillDensity ?? form.get('infillDensity')?.value
"
(change)="
updateSelectedItemNumberField(
'infillDensity',
+$any($event.target).value
)
"
/>
</label>
<label class="item-settings-checkbox">
<input
type="checkbox"
[checked]="
selectedItem.supportEnabled ?? form.get('supportEnabled')?.value
"
(change)="updateSelectedItemSupport($any($event.target).checked)"
/>
<span>{{ "CALC.SUPPORT" | translate }}</span>
</label>
</div>
}
</div>
}
}
}
@if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
}
</div>
<app-input <app-input
formControlName="notes" formControlName="notes"
[label]="'CALC.NOTES' | translate" [label]="'CALC.NOTES' | translate"

View File

@@ -2,9 +2,9 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.upload-privacy-note { .upload-privacy-note {
margin-top: var(--space-3); margin-top: var(--space-6);
margin-bottom: 0; margin-bottom: 0;
font-size: 0.78rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: left; text-align: left;
} }
@@ -211,6 +211,12 @@
} }
} }
.sync-all-row {
margin-top: var(--space-2);
margin-bottom: var(--space-4);
padding-top: 0;
}
/* Progress Bar */ /* Progress Bar */
.progress-container { .progress-container {
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
@@ -244,3 +250,74 @@
color: var(--color-text-muted); color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
} }
.item-settings-panel {
margin-top: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
}
.item-settings-title {
margin: 0 0 var(--space-4);
font-size: 1.05rem;
color: var(--color-text);
}
.item-settings-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
margin-bottom: var(--space-3);
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.item-settings-grid label,
.item-settings-checkbox {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.item-settings-grid input,
.item-settings-grid select {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
background: var(--color-bg-card);
font-size: 1rem;
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25);
}
}
.item-settings-checkbox {
flex-direction: row;
align-items: center;
gap: var(--space-2);
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--color-brand);
}
}
.item-settings-checkbox--top {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
color: var(--color-text);
font-size: 1rem;
font-weight: 500;
}

View File

@@ -16,7 +16,6 @@ import {
} from '@angular/forms'; } 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 { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component'; import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
@@ -35,10 +34,34 @@ interface FormItem {
file: File; file: File;
previewFile?: File; previewFile?: File;
quantity: number; quantity: number;
material?: string;
quality?: string;
color: string; color: string;
filamentVariantId?: number; filamentVariantId?: number;
supportEnabled?: boolean;
infillDensity?: number;
infillPattern?: string;
layerHeight?: number;
nozzleDiameter?: number;
printSettings: ItemPrintSettings;
} }
interface ItemPrintSettings {
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}
interface ItemSettingsDiffInfo {
differences: string[];
}
type ItemPrintSettingsUpdate = Partial<ItemPrintSettings>;
@Component({ @Component({
selector: 'app-upload-form', selector: 'app-upload-form',
standalone: true, standalone: true,
@@ -47,7 +70,6 @@ interface FormItem {
ReactiveFormsModule, ReactiveFormsModule,
TranslateModule, TranslateModule,
AppInputComponent, AppInputComponent,
AppSelectComponent,
AppDropzoneComponent, AppDropzoneComponent,
AppButtonComponent, AppButtonComponent,
StlViewerComponent, StlViewerComponent,
@@ -62,6 +84,22 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false); loading = input<boolean>(false);
uploadProgress = input<number>(0); uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>(); submitRequest = output<QuoteRequest>();
itemQuantityChange = output<{
index: number;
fileName: string;
quantity: number;
}>();
itemSettingsDiffChange = output<Record<string, ItemSettingsDiffInfo>>();
printSettingsChange = output<{
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}>();
private estimator = inject(QuoteEstimatorService); private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -81,7 +119,10 @@ export class UploadFormComponent implements OnInit {
// Store full material options to lookup variants/colors if needed later // Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = []; private fullMaterialOptions: MaterialOption[] = [];
private allLayerHeights: SimpleOption[] = [];
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false; private isPatchingSettings = false;
sameSettingsForAll = signal(true);
// Computed variants for valid material // Computed variants for valid material
currentMaterialVariants = signal<VariantOption[]>([]); currentMaterialVariants = signal<VariantOption[]>([]);
@@ -91,7 +132,7 @@ export class UploadFormComponent implements OnInit {
if (matCode && this.fullMaterialOptions.length > 0) { if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find((m) => m.code === matCode); const found = this.fullMaterialOptions.find((m) => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []); this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections(); this.syncSelectedItemVariantSelection();
} else { } else {
this.currentMaterialVariants.set([]); this.currentMaterialVariants.set([]);
} }
@@ -117,9 +158,28 @@ export class UploadFormComponent implements OnInit {
return item.previewFile ?? item.file; return item.previewFile ?? item.file;
} }
getSelectedItemIndex(): number {
const selected = this.selectedFile();
if (!selected) return -1;
return this.items().findIndex((item) => item.file === selected);
}
getSelectedItem(): FormItem | null {
const index = this.getSelectedItemIndex();
if (index < 0) return null;
return this.items()[index] ?? null;
}
getVariantsForMaterial(materialCode: string | null | undefined): VariantOption[] {
if (!materialCode) return [];
const found = this.fullMaterialOptions.find((m) => m.code === materialCode);
return found?.variants ?? [];
}
constructor() { constructor() {
this.form = this.fb.group({ this.form = this.fb.group({
itemsTouched: [false], // Hack to track touched state for custom items list itemsTouched: [false], // Hack to track touched state for custom items list
syncAllItems: [true],
material: ['', Validators.required], material: ['', Validators.required],
quality: ['', Validators.required], quality: ['', Validators.required],
items: [[]], // Track items in form for validation if needed items: [[]], // Track items in form for validation if needed
@@ -132,14 +192,60 @@ export class UploadFormComponent implements OnInit {
supportEnabled: [false], supportEnabled: [false],
}); });
// Listen to material changes to update variants // Listen to material changes to update variants and propagate when "all files equal" is active.
this.form.get('material')?.valueChanges.subscribe(() => { this.form.get('material')?.valueChanges.subscribe((materialCode) => {
this.updateVariants(); this.updateVariants();
if (this.sameSettingsForAll() && !this.isPatchingSettings) {
this.applyGlobalMaterialToAll(String(materialCode || 'PLA'));
}
}); });
this.form.get('quality')?.valueChanges.subscribe((quality) => { this.form.get('quality')?.valueChanges.subscribe((quality) => {
if (this.mode() !== 'easy' || this.isPatchingSettings) return; if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality); this.applyAdvancedPresetFromQuality(quality);
if (this.sameSettingsForAll()) {
this.applyGlobalFieldToAll('quality', String(quality || 'standard'));
}
});
this.form.get('nozzleDiameter')?.valueChanges.subscribe((value) => {
if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
this.applyGlobalFieldToAll(
'nozzleDiameter',
Number.isFinite(Number(value)) ? Number(value) : 0.4,
);
});
this.form.get('layerHeight')?.valueChanges.subscribe((value) => {
if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
this.applyGlobalFieldToAll(
'layerHeight',
Number.isFinite(Number(value)) ? Number(value) : 0.2,
);
});
this.form.get('infillDensity')?.valueChanges.subscribe((value) => {
if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
this.applyGlobalFieldToAll(
'infillDensity',
Number.isFinite(Number(value)) ? Number(value) : 15,
);
});
this.form.get('infillPattern')?.valueChanges.subscribe((value) => {
if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
this.applyGlobalFieldToAll('infillPattern', String(value || 'grid'));
});
this.form.get('supportEnabled')?.valueChanges.subscribe((value) => {
if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
this.applyGlobalFieldToAll('supportEnabled', !!value);
});
this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => {
if (this.isPatchingSettings) return;
this.updateLayerHeightOptionsForNozzle(nozzle, true);
});
this.form.valueChanges.subscribe(() => {
if (this.isPatchingSettings) return;
this.syncSelectedItemSettingsFromForm();
this.emitPrintSettingsChange();
this.emitItemSettingsDiffChange();
}); });
effect(() => { effect(() => {
@@ -187,6 +293,7 @@ export class UploadFormComponent implements OnInit {
const preset = presets[normalized] || presets['standard']; const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false }); this.form.patchValue(preset, { emitEvent: false });
this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true);
} }
ngOnInit() { ngOnInit() {
@@ -204,9 +311,19 @@ export class UploadFormComponent implements OnInit {
this.infillPatterns.set( this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })), options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
); );
this.layerHeights.set( this.allLayerHeights = options.layerHeights.map((l) => ({
options.layerHeights.map((l) => ({ label: l.label, value: l.value })), label: l.label,
); value: l.value,
}));
this.layerHeightsByNozzle = {};
(options.layerHeightsByNozzle || []).forEach((entry) => {
this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] =
entry.layerHeights.map((layer) => ({
label: layer.label,
value: layer.value,
}));
});
this.layerHeights.set(this.allLayerHeights);
this.nozzleDiameters.set( this.nozzleDiameters.set(
options.nozzleDiameters.map((n) => ({ options.nozzleDiameters.map((n) => ({
label: n.label, label: n.label,
@@ -231,6 +348,11 @@ export class UploadFormComponent implements OnInit {
value: 'standard', value: 'standard',
}, },
]); ]);
this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }];
this.layerHeightsByNozzle = {
[this.toNozzleKey(0.4)]: this.allLayerHeights,
};
this.layerHeights.set(this.allLayerHeights);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults(); this.setDefaults();
}, },
@@ -240,7 +362,16 @@ export class UploadFormComponent implements OnInit {
private setDefaults() { private setDefaults() {
// Set Defaults if available // Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) { if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value); const exactPla = this.materials().find(
(m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA',
);
const anyPla = this.materials().find(
(m) =>
typeof m.value === 'string' &&
m.value.toUpperCase().startsWith('PLA'),
);
const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0];
this.form.get('material')?.setValue(preferredMaterial.value);
} }
if (this.qualities().length > 0 && !this.form.get('quality')?.value) { if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first // Try to find 'standard' or use first
@@ -255,36 +386,47 @@ export class UploadFormComponent implements OnInit {
) { ) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
} }
if (
this.layerHeights().length > 0 && this.updateLayerHeightOptionsForNozzle(
!this.form.get('layerHeight')?.value this.form.get('nozzleDiameter')?.value,
) { true,
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 );
}
if ( if (
this.infillPatterns().length > 0 && this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value !this.form.get('infillPattern')?.value
) { ) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
} }
this.emitPrintSettingsChange();
} }
onFilesDropped(newFiles: File[]) { onFilesDropped(newFiles: File[]) {
const MAX_SIZE = 200 * 1024 * 1024; // 200MB const MAX_SIZE = 200 * 1024 * 1024; // 200MB
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
let hasError = false; let hasError = false;
const defaults = this.getCurrentGlobalItemDefaults();
for (const file of newFiles) { for (const file of newFiles) {
if (file.size > MAX_SIZE) { if (file.size > MAX_SIZE) {
hasError = true; hasError = true;
} else { } else {
const defaultSelection = this.getDefaultVariantSelection(); const defaultSelection = this.getDefaultVariantSelection(defaults.material);
validItems.push({ validItems.push({
file, file,
previewFile: this.isStlFile(file) ? file : undefined, previewFile: this.isStlFile(file) ? file : undefined,
quantity: 1, quantity: 1,
material: defaults.material,
quality: defaults.quality,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId, filamentVariantId: defaultSelection.filamentVariantId,
supportEnabled: defaults.supportEnabled,
infillDensity: defaults.infillDensity,
infillPattern: defaults.infillPattern,
layerHeight: defaults.layerHeight,
nozzleDiameter: defaults.nozzleDiameter,
printSettings: this.getCurrentItemPrintSettings(),
}); });
} }
} }
@@ -297,7 +439,8 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => [...current, ...validItems]); this.items.update((current) => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true); this.form.get('itemsTouched')?.setValue(true);
// Auto select last added // Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file); this.selectFile(validItems[validItems.length - 1].file);
this.emitItemSettingsDiffChange();
} }
} }
@@ -316,19 +459,25 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => { this.items.update((current) => {
if (index >= current.length) return current; if (index >= current.length) return current;
const updated = [...current]; const applyToAll = this.sameSettingsForAll();
updated[index] = { ...updated[index], quantity: normalizedQty }; return current.map((item, idx) => {
return updated; if (!applyToAll && idx !== index) return item;
return { ...item, quantity: normalizedQty };
});
}); });
} }
updateItemQuantityByName(fileName: string, quantity: number) { updateItemQuantityByName(fileName: string, quantity: number) {
const targetName = this.normalizeFileName(fileName); const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity); const normalizedQty = this.normalizeQuantity(quantity);
const applyToAll = this.sameSettingsForAll();
this.items.update((current) => { this.items.update((current) => {
let matched = false; let matched = false;
return current.map((item) => { return current.map((item) => {
if (applyToAll) {
return { ...item, quantity: normalizedQty };
}
if (!matched && this.normalizeFileName(item.file.name) === targetName) { if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true; matched = true;
return { ...item, quantity: normalizedQty }; return { ...item, quantity: normalizedQty };
@@ -344,6 +493,7 @@ export class UploadFormComponent implements OnInit {
} else { } else {
this.selectedFile.set(file); this.selectedFile.set(file);
} }
this.loadSelectedItemSettingsIntoForm();
} }
// Helper to get color of currently selected file // Helper to get color of currently selected file
@@ -353,7 +503,7 @@ export class UploadFormComponent implements OnInit {
const item = this.items().find((i) => i.file === file); const item = this.items().find((i) => i.file === file);
if (item) { if (item) {
const vars = this.currentMaterialVariants(); const vars = this.getVariantsForMaterial(item.material);
if (vars && vars.length > 0) { if (vars && vars.length > 0) {
const found = item.filamentVariantId const found = item.filamentVariantId
? vars.find((v) => v.id === item.filamentVariantId) ? vars.find((v) => v.id === item.filamentVariantId)
@@ -369,7 +519,15 @@ export class UploadFormComponent implements OnInit {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10); const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1; const quantity = Number.isFinite(parsed) ? parsed : 1;
const currentItem = this.items()[index];
if (!currentItem) return;
const normalizedQty = this.normalizeQuantity(quantity);
this.updateItemQuantityByIndex(index, quantity); this.updateItemQuantityByIndex(index, quantity);
this.itemQuantityChange.emit({
index,
fileName: currentItem.file.name,
quantity: normalizedQty,
});
} }
updateItemColor( updateItemColor(
@@ -384,36 +542,277 @@ export class UploadFormComponent implements OnInit {
: newSelection.filamentVariantId; : newSelection.filamentVariantId;
this.items.update((current) => { this.items.update((current) => {
const updated = [...current]; const updated = [...current];
updated[index] = { const applyToAll = this.sameSettingsForAll();
...updated[index], return updated.map((item, idx) => {
if (!applyToAll && idx !== index) return item;
return {
...item,
color: colorName, color: colorName,
filamentVariantId, filamentVariantId,
}; };
return updated; });
}); });
} }
updateItemMaterial(index: number, materialCode: string) {
if (!Number.isInteger(index) || index < 0) return;
const variants = this.getVariantsForMaterial(materialCode);
const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
this.items.update((current) => {
if (index >= current.length) return current;
const applyToAll = this.sameSettingsForAll();
return current.map((item, idx) => {
if (!applyToAll && idx !== index) return item;
const next = { ...item, material: materialCode };
if (fallback) {
next.color = fallback.colorName;
next.filamentVariantId = fallback.id;
} else {
next.filamentVariantId = undefined;
}
return next;
});
});
}
updateSelectedItemNumberField(
field:
| 'nozzleDiameter'
| 'layerHeight'
| 'infillDensity'
| 'quantity',
value: number,
) {
const index = this.getSelectedItemIndex();
if (index < 0) return;
const normalized =
field === 'quantity'
? this.normalizeQuantity(value)
: Number.isFinite(value)
? value
: undefined;
this.items.update((current) => {
if (index >= current.length) return current;
const applyToAll = this.sameSettingsForAll();
return current.map((item, idx) => {
if (!applyToAll && idx !== index) return item;
return {
...item,
[field]: normalized,
};
});
});
}
updateSelectedItemStringField(
field: 'quality' | 'infillPattern',
value: string,
) {
const index = this.getSelectedItemIndex();
if (index < 0) return;
this.items.update((current) => {
if (index >= current.length) return current;
const applyToAll = this.sameSettingsForAll();
return current.map((item, idx) => {
if (!applyToAll && idx !== index) return item;
return {
...item,
[field]: value,
};
});
});
}
updateSelectedItemSupport(value: boolean) {
const index = this.getSelectedItemIndex();
if (index < 0) return;
this.items.update((current) => {
if (index >= current.length) return current;
const applyToAll = this.sameSettingsForAll();
return current.map((item, idx) => {
if (!applyToAll && idx !== index) return item;
return {
...item,
supportEnabled: value,
};
});
});
}
onSameSettingsToggle(enabled: boolean) {
this.sameSettingsForAll.set(enabled);
if (!enabled) {
// Keep per-file values aligned with what the user sees in global controls
// right before switching to single-file mode.
this.syncAllItemsWithGlobalForm();
return;
}
const selected = this.getSelectedItem() ?? this.items()[0];
if (!selected) return;
const normalizedQuality = this.normalizeQualityValue(
selected.quality ?? this.form.get('quality')?.value,
);
this.isPatchingSettings = true;
this.form.patchValue(
{
material: selected.material || this.form.get('material')?.value || 'PLA',
quality: normalizedQuality,
nozzleDiameter:
selected.nozzleDiameter ?? this.form.get('nozzleDiameter')?.value ?? 0.4,
layerHeight:
selected.layerHeight ?? this.form.get('layerHeight')?.value ?? 0.2,
infillDensity:
selected.infillDensity ?? this.form.get('infillDensity')?.value ?? 15,
infillPattern:
selected.infillPattern || this.form.get('infillPattern')?.value || 'grid',
supportEnabled:
selected.supportEnabled ??
this.form.get('supportEnabled')?.value ??
false,
},
{ emitEvent: false },
);
this.isPatchingSettings = false;
const sharedPatch: Partial<FormItem> = {
quantity: selected.quantity,
material: selected.material,
quality: normalizedQuality,
color: selected.color,
filamentVariantId: selected.filamentVariantId,
supportEnabled: selected.supportEnabled,
infillDensity: selected.infillDensity,
infillPattern: selected.infillPattern,
layerHeight: selected.layerHeight,
nozzleDiameter: selected.nozzleDiameter,
};
this.items.update((current) =>
current.map((item) => ({
...item,
...sharedPatch,
})),
);
}
private applyGlobalMaterialToAll(materialCode: string): void {
const normalizedMaterial = materialCode || 'PLA';
const variants = this.getVariantsForMaterial(normalizedMaterial);
const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
this.items.update((current) =>
current.map((item) => ({
...item,
material: normalizedMaterial,
color: fallback ? fallback.colorName : item.color,
filamentVariantId: fallback ? fallback.id : item.filamentVariantId,
})),
);
}
private applyGlobalFieldToAll(
field:
| 'quality'
| 'nozzleDiameter'
| 'layerHeight'
| 'infillDensity'
| 'infillPattern'
| 'supportEnabled',
value: string | number | boolean,
): void {
this.items.update((current) =>
current.map((item) => ({
...item,
[field]: value,
})),
);
}
patchItemSettingsByIndex(index: number, patch: Partial<FormItem>) {
if (!Number.isInteger(index) || index < 0) return;
const normalizedPatch: Partial<FormItem> = { ...patch };
if (normalizedPatch.quality !== undefined && normalizedPatch.quality !== null) {
normalizedPatch.quality = this.normalizeQualityValue(normalizedPatch.quality);
}
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], ...normalizedPatch };
return updated;
});
this.emitItemSettingsDiffChange();
}
setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) {
if (!Number.isInteger(index) || index < 0) return;
let selectedItemUpdated = false;
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
const target = updated[index];
if (!target) return current;
const merged: ItemPrintSettings = {
...target.printSettings,
...update,
};
updated[index] = {
...target,
printSettings: merged,
};
selectedItemUpdated = target.file === this.selectedFile();
return updated;
});
if (selectedItemUpdated) {
this.loadSelectedItemSettingsIntoForm();
this.emitPrintSettingsChange();
}
this.emitItemSettingsDiffChange();
}
removeItem(index: number) { removeItem(index: number) {
let nextSelected: File | null = null;
this.items.update((current) => { this.items.update((current) => {
const updated = [...current]; const updated = [...current];
const removed = updated.splice(index, 1)[0]; const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) { if (this.selectedFile() === removed.file) {
this.selectedFile.set(null); nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null;
} }
return updated; return updated;
}); });
if (nextSelected) {
this.selectFile(nextSelected);
} else if (this.items().length === 0) {
this.selectedFile.set(null);
}
this.emitItemSettingsDiffChange();
} }
setFiles(files: File[]) { setFiles(files: File[]) {
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection(); const defaults = this.getCurrentGlobalItemDefaults();
const defaultSelection = this.getDefaultVariantSelection(defaults.material);
for (const file of files) { for (const file of files) {
validItems.push({ validItems.push({
file, file,
previewFile: this.isStlFile(file) ? file : undefined, previewFile: this.isStlFile(file) ? file : undefined,
quantity: 1, quantity: 1,
material: defaults.material,
quality: defaults.quality,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId, filamentVariantId: defaultSelection.filamentVariantId,
supportEnabled: defaults.supportEnabled,
infillDensity: defaults.infillDensity,
infillPattern: defaults.infillPattern,
layerHeight: defaults.layerHeight,
nozzleDiameter: defaults.nozzleDiameter,
}); });
} }
@@ -421,7 +820,8 @@ export class UploadFormComponent implements OnInit {
this.items.set(validItems); this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true); this.form.get('itemsTouched')?.setValue(true);
// Auto select last added // Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file); this.selectFile(validItems[validItems.length - 1].file);
this.emitItemSettingsDiffChange();
} }
} }
@@ -435,11 +835,28 @@ export class UploadFormComponent implements OnInit {
}); });
} }
private getDefaultVariantSelection(): { private getCurrentGlobalItemDefaults(): Omit<FormItem, 'file' | 'previewFile' | 'quantity' | 'color' | 'filamentVariantId'> & {
material: string;
quality: string;
} {
return {
material: this.form.get('material')?.value || 'PLA',
quality: this.normalizeQualityValue(this.form.get('quality')?.value),
supportEnabled: !!this.form.get('supportEnabled')?.value,
infillDensity: Number(this.form.get('infillDensity')?.value ?? 15),
infillPattern: this.form.get('infillPattern')?.value || 'grid',
layerHeight: Number(this.form.get('layerHeight')?.value ?? 0.2),
nozzleDiameter: Number(this.form.get('nozzleDiameter')?.value ?? 0.4),
};
}
private getDefaultVariantSelection(materialCode?: string): {
colorName: string; colorName: string;
filamentVariantId?: number; filamentVariantId?: number;
} { } {
const vars = this.currentMaterialVariants(); const vars = materialCode
? this.getVariantsForMaterial(materialCode)
: this.currentMaterialVariants();
if (vars && vars.length > 0) { if (vars && vars.length > 0) {
const preferred = vars.find((v) => !v.isOutOfStock) || vars[0]; const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
return { return {
@@ -450,25 +867,48 @@ export class UploadFormComponent implements OnInit {
return { colorName: 'Black' }; return { colorName: 'Black' };
} }
private syncItemVariantSelections(): void { getVariantsForItem(item: FormItem): VariantOption[] {
return this.getVariantsForMaterialCode(item.printSettings.material);
}
private getVariantsForMaterialCode(materialCodeRaw: string): VariantOption[] {
const materialCode = String(materialCodeRaw || '').toUpperCase();
if (!materialCode) {
return [];
}
const material = this.fullMaterialOptions.find(
(option) => String(option.code || '').toUpperCase() === materialCode,
);
return material?.variants || [];
}
private syncSelectedItemVariantSelection(): void {
const vars = this.currentMaterialVariants(); const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) { if (!vars || vars.length === 0) {
return; return;
} }
const selected = this.selectedFile();
if (!selected) {
return;
}
const fallback = vars.find((v) => !v.isOutOfStock) || vars[0]; const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
this.items.update((current) => this.items.update((current) =>
current.map((item) => { current.map((item) => {
if (item.file !== selected) {
return item;
}
const byId = const byId =
item.filamentVariantId != null item.filamentVariantId != null
? vars.find((v) => v.id === item.filamentVariantId) ? vars.find((v) => v.id === item.filamentVariantId)
: null; : null;
const byColor = vars.find((v) => v.colorName === item.color); const byColor = vars.find((v) => v.colorName === item.color);
const selected = byId || byColor || fallback; const selectedVariant = byId || byColor || fallback;
return { return {
...item, ...item,
color: selected.colorName, color: selectedVariant.colorName,
filamentVariantId: selected.id, filamentVariantId: selectedVariant.id,
}; };
}), }),
); );
@@ -514,6 +954,11 @@ export class UploadFormComponent implements OnInit {
this.isPatchingSettings = true; this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false }); this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false; this.isPatchingSettings = false;
this.updateLayerHeightOptionsForNozzle(
this.form.get('nozzleDiameter')?.value,
true,
);
this.emitPrintSettingsChange();
} }
onSubmit() { onSubmit() {
@@ -521,6 +966,8 @@ export class UploadFormComponent implements OnInit {
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) { if (this.form.valid && this.items().length > 0) {
const items = this.items();
const firstItemMaterial = items[0]?.material;
console.log( console.log(
'UploadFormComponent: Emitting submitRequest', 'UploadFormComponent: Emitting submitRequest',
this.form.value, this.form.value,
@@ -563,6 +1010,7 @@ export class UploadFormComponent implements OnInit {
private applySettingsLock(locked: boolean): void { private applySettingsLock(locked: boolean): void {
const controlsToLock = [ const controlsToLock = [
'syncAllItems',
'material', 'material',
'quality', 'quality',
'nozzleDiameter', 'nozzleDiameter',

View File

@@ -1,16 +1,24 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http'; import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
export interface QuoteRequest { export interface QuoteRequestItem {
items: {
file: File; file: File;
quantity: number; quantity: number;
material?: string;
quality?: string;
color?: string; color?: string;
filamentVariantId?: number; filamentVariantId?: number;
}[]; supportEnabled?: boolean;
infillDensity?: number;
infillPattern?: string;
layerHeight?: number;
nozzleDiameter?: number;
}
export interface QuoteRequest {
items: QuoteRequestItem[];
material: string; material: string;
quality: string; quality: string;
notes?: string; notes?: string;
@@ -26,12 +34,18 @@ export interface QuoteItem {
id?: string; id?: string;
fileName: string; fileName: string;
unitPrice: number; unitPrice: number;
unitTime: number; // seconds unitTime: number;
unitWeight: number; // grams unitWeight: number;
quantity: number; quantity: number;
material?: string; material?: string;
quality?: string;
color?: string; color?: string;
filamentVariantId?: number; filamentVariantId?: number;
supportEnabled?: boolean;
infillDensity?: number;
infillPattern?: string;
layerHeight?: number;
nozzleDiameter?: number;
} }
export interface QuoteResult { export interface QuoteResult {
@@ -49,36 +63,12 @@ export interface QuoteResult {
notes?: string; notes?: string;
} }
interface BackendResponse {
success: boolean;
data: {
print_time_seconds: number;
material_grams: number;
cost: {
total: number;
};
};
error?: string;
}
interface BackendQuoteResult {
totalPrice: number;
currency: string;
setupCost: number;
stats: {
printTimeSeconds: number;
printTimeFormatted: string;
filamentWeightGrams: number;
filamentLengthMm: number;
};
}
// Options Interfaces
export interface MaterialOption { export interface MaterialOption {
code: string; code: string;
label: string; label: string;
variants: VariantOption[]; variants: VariantOption[];
} }
export interface VariantOption { export interface VariantOption {
id: number; id: number;
name: string; name: string;
@@ -89,28 +79,36 @@ export interface VariantOption {
stockFilamentGrams: number; stockFilamentGrams: number;
isOutOfStock: boolean; isOutOfStock: boolean;
} }
export interface QualityOption { export interface QualityOption {
id: string; id: string;
label: string; label: string;
} }
export interface InfillOption { export interface InfillOption {
id: string; id: string;
label: string; label: string;
} }
export interface NumericOption { export interface NumericOption {
value: number; value: number;
label: string; label: string;
} }
export interface NozzleLayerHeightOptions {
nozzleDiameter: number;
layerHeights: NumericOption[];
}
export interface OptionsResponse { export interface OptionsResponse {
materials: MaterialOption[]; materials: MaterialOption[];
qualities: QualityOption[]; qualities: QualityOption[];
infillPatterns: InfillOption[]; infillPatterns: InfillOption[];
layerHeights: NumericOption[]; layerHeights: NumericOption[];
nozzleDiameters: NumericOption[]; nozzleDiameters: NumericOption[];
layerHeightsByNozzle: NozzleLayerHeightOptions[];
} }
// UI Option for Select Component
export interface SimpleOption { export interface SimpleOption {
value: string | number; value: string | number;
label: string; label: string;
@@ -122,70 +120,23 @@ export interface SimpleOption {
export class QuoteEstimatorService { export class QuoteEstimatorService {
private http = inject(HttpClient); private http = inject(HttpClient);
private buildEasyModePreset(quality: string | undefined): { private pendingConsultation = signal<{
quality: string; files: File[];
layerHeight: number; message: string;
infillDensity: number; } | null>(null);
infillPattern: string;
nozzleDiameter: number;
} {
const normalized = (quality || 'standard').toLowerCase();
// Legacy alias support.
if (normalized === 'high' || normalized === 'extra_fine') {
return {
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
if (normalized === 'draft') {
return {
quality: 'extra_fine',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
getOptions(): Observable<OptionsResponse> { getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {}; const headers: any = {};
return this.http return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
headers, headers,
}) });
.pipe(
tap({
next: (res) =>
console.log('QuoteEstimatorService: Options loaded', res),
error: (err) =>
console.error('QuoteEstimatorService: Options failed', err),
}),
);
} }
// NEW METHODS for Order Flow
getQuoteSession(sessionId: string): Observable<any> { getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.get( return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
`${environment.apiUrl}/api/quote-sessions/${sessionId}`, headers,
{ headers }, });
);
} }
updateLineItem(lineItemId: string, changes: any): Observable<any> { updateLineItem(lineItemId: string, changes: any): Observable<any> {
@@ -224,13 +175,10 @@ export class QuoteEstimatorService {
getOrderInvoice(orderId: string): Observable<Blob> { getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
return this.http.get( return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
{
headers, headers,
responseType: 'blob', responseType: 'blob',
}, });
);
} }
getOrderConfirmation(orderId: string): Observable<Blob> { getOrderConfirmation(orderId: string): Observable<Blob> {
@@ -252,73 +200,68 @@ export class QuoteEstimatorService {
} }
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request); if (!request.items || request.items.length === 0) {
if (request.items.length === 0) { return of(0);
console.warn('QuoteEstimatorService: No items to calculate');
return of();
} }
return new Observable((observer) => { return new Observable<number | QuoteResult>((observer) => {
// 1. Create Session first
const headers: any = {}; const headers: any = {};
this.http this.http
.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }) .post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
.subscribe({ .subscribe({
next: (sessionRes) => { next: (sessionRes) => {
const sessionId = sessionRes.id; const sessionId = String(sessionRes?.id || '');
const sessionSetupCost = sessionRes.setupCostChf || 0; if (!sessionId) {
observer.error('Could not initialize quote session');
return;
}
// 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 uploadProgress = new Array(totalItems).fill(0);
const finalResponses: any[] = []; const uploadResults: { success: boolean }[] = new Array(totalItems)
let completedRequests = 0; .fill(null)
.map(() => ({ success: false }));
let completed = 0;
const checkCompletion = () => { const emitProgress = () => {
const avg = Math.round( const avg = Math.round(
allProgress.reduce((a, b) => a + b, 0) / totalItems, uploadProgress.reduce((sum, value) => sum + value, 0) / totalItems,
); );
observer.next(avg); observer.next(avg);
};
if (completedRequests === totalItems) { const finalize = () => {
finalize(finalResponses, sessionSetupCost, sessionId); emitProgress();
if (completed !== totalItems) {
return;
} }
const hasFailure = uploadResults.some((entry) => !entry.success);
if (hasFailure) {
observer.error('One or more files failed during upload/analysis');
return;
}
this.getQuoteSession(sessionId).subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: () => {
observer.error('Failed to calculate final quote');
},
});
}; };
request.items.forEach((item, index) => { request.items.forEach((item, index) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', item.file); formData.append('file', item.file);
const easyPreset = const settings = this.buildSettingsPayload(request, item);
request.mode === 'easy'
? this.buildEasyModePreset(request.quality)
: null;
const settings = {
complexityMode:
request.mode === 'easy'
? 'ADVANCED'
: request.mode.toUpperCase(),
material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset
? easyPreset.layerHeight
: request.layerHeight,
infillDensity: easyPreset
? easyPreset.infillDensity
: request.infillDensity,
infillPattern: easyPreset
? easyPreset.infillPattern
: request.infillPattern,
nozzleDiameter: easyPreset
? easyPreset.nozzleDiameter
: request.nozzleDiameter,
};
const settingsBlob = new Blob([JSON.stringify(settings)], { const settingsBlob = new Blob([JSON.stringify(settings)], {
type: 'application/json', type: 'application/json',
}); });
@@ -340,84 +283,46 @@ export class QuoteEstimatorService {
event.type === HttpEventType.UploadProgress && event.type === HttpEventType.UploadProgress &&
event.total event.total
) { ) {
allProgress[index] = Math.round( uploadProgress[index] = Math.round(
(100 * event.loaded) / event.total, (100 * event.loaded) / event.total,
); );
checkCompletion(); emitProgress();
} else if (event.type === HttpEventType.Response) { return;
allProgress[index] = 100; }
finalResponses[index] = {
...event.body, if (event.type === HttpEventType.Response) {
success: true, uploadProgress[index] = 100;
fileName: item.file.name, uploadResults[index] = { success: true };
originalQty: item.quantity, completed += 1;
originalItem: item, finalize();
};
completedRequests++;
checkCompletion();
} }
}, },
error: (err) => { error: () => {
console.error('Item upload failed', err); uploadProgress[index] = 100;
finalResponses[index] = { uploadResults[index] = { success: false };
success: false, completed += 1;
fileName: item.file.name, finalize();
};
completedRequests++;
checkCompletion();
}, },
}); });
}); });
}, },
error: (err) => { error: () => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session'); observer.error('Could not initialize quote session');
}, },
}); });
const finalize = (
responses: any[],
setupCost: number,
sessionId: string,
) => {
this.http
.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
headers,
})
.subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: (err) => {
console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote');
},
});
};
}); });
} }
// Consultation Data Transfer
private pendingConsultation = signal<{
files: File[];
message: string;
} | null>(null);
setPendingConsultation(data: { files: File[]; message: string }) { setPendingConsultation(data: { files: File[]; message: string }) {
this.pendingConsultation.set(data); this.pendingConsultation.set(data);
} }
getPendingConsultation() { getPendingConsultation() {
const data = this.pendingConsultation(); const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading this.pendingConsultation.set(null);
return data; return data;
} }
// Session File Retrieval
getLineItemContent( getLineItemContent(
sessionId: string, sessionId: string,
lineItemId: string, lineItemId: string,
@@ -434,48 +339,156 @@ export class QuoteEstimatorService {
); );
} }
getLineItemStlPreview(
sessionId: string,
lineItemId: string,
): Observable<Blob> {
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/stl-preview`,
{
headers,
responseType: 'blob',
},
);
}
mapSessionToQuoteResult(sessionData: any): QuoteResult { mapSessionToQuoteResult(sessionData: any): QuoteResult {
const session = sessionData.session; const session = sessionData?.session || {};
const items = sessionData.items || []; const items = Array.isArray(sessionData?.items) ? sessionData.items : [];
const totalTime = items.reduce( const totalTime = items.reduce(
(acc: number, item: any) => (acc: number, item: any) =>
acc + (item.printTimeSeconds || 0) * item.quantity, acc + Number(item?.printTimeSeconds || 0) * Number(item?.quantity || 1),
0,
);
const totalWeight = items.reduce(
(acc: number, item: any) =>
acc + (item.materialGrams || 0) * item.quantity,
0, 0,
); );
const totalWeight = items.reduce(
(acc: number, item: any) =>
acc + Number(item?.materialGrams || 0) * Number(item?.quantity || 1),
0,
);
const grandTotal = Number(sessionData?.grandTotalChf);
const fallbackTotal =
Number(sessionData?.itemsTotalChf || 0) +
Number(session?.setupCostChf || 0) +
Number(sessionData?.shippingCostChf || 0);
return { return {
sessionId: session.id, sessionId: session?.id,
items: items.map((item: any) => ({ items: items.map((item: any) => ({
id: item.id, id: item?.id,
fileName: item.originalFilename, fileName: item?.originalFilename,
unitPrice: item.unitPriceChf, unitPrice: Number(item?.unitPriceChf || 0),
unitTime: item.printTimeSeconds, unitTime: Number(item?.printTimeSeconds || 0),
unitWeight: item.materialGrams, unitWeight: Number(item?.materialGrams || 0),
quantity: item.quantity, quantity: Number(item?.quantity || 1),
material: session.materialCode, // Assumption: session has one material for all? or items have it? material: item?.materialCode || session?.materialCode,
// Backend model QuoteSession has materialCode. quality: item?.quality,
// But line items might have different colors. color: item?.colorCode,
color: item.colorCode, filamentVariantId: item?.filamentVariantId,
filamentVariantId: item.filamentVariantId, supportEnabled: Boolean(item?.supportsEnabled),
infillDensity:
item?.infillPercent != null ? Number(item.infillPercent) : undefined,
infillPattern: item?.infillPattern,
layerHeight:
item?.layerHeightMm != null ? Number(item.layerHeightMm) : undefined,
nozzleDiameter:
item?.nozzleDiameterMm != null
? Number(item.nozzleDiameterMm)
: undefined,
})), })),
setupCost: session.setupCostChf || 0, setupCost: Number(session?.setupCostChf || 0),
globalMachineCost: sessionData.globalMachineCostChf || 0, globalMachineCost: Number(sessionData?.globalMachineCostChf || 0),
cadHours: session.cadHours || 0, cadHours: Number(session?.cadHours || 0),
cadTotal: sessionData.cadTotalChf || 0, cadTotal: Number(sessionData?.cadTotalChf || 0),
currency: 'CHF', // Fixed for now currency: 'CHF',
totalPrice: totalPrice: Number.isFinite(grandTotal) ? grandTotal : fallbackTotal,
(sessionData.itemsTotalChf || 0) +
(session.setupCostChf || 0) +
(sessionData.shippingCostChf || 0),
totalTimeHours: Math.floor(totalTime / 3600), totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight), totalWeight: Math.ceil(totalWeight),
notes: session.notes, notes: session?.notes,
};
}
private buildSettingsPayload(request: QuoteRequest, item: QuoteRequestItem): any {
const normalizedQuality = this.normalizeQuality(item.quality || request.quality);
const easyPreset =
request.mode === 'easy'
? this.buildEasyModePreset(normalizedQuality)
: null;
return {
complexityMode: request.mode === 'easy' ? 'BASIC' : 'ADVANCED',
material: String(item.material || request.material || 'PLA'),
color: item.color || '#FFFFFF',
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : normalizedQuality,
supportsEnabled: item.supportEnabled ?? request.supportEnabled ?? false,
layerHeight:
easyPreset?.layerHeight ?? item.layerHeight ?? request.layerHeight ?? 0.2,
infillDensity:
easyPreset?.infillDensity ??
item.infillDensity ??
request.infillDensity ??
20,
infillPattern:
easyPreset?.infillPattern ??
item.infillPattern ??
request.infillPattern ??
'grid',
nozzleDiameter:
easyPreset?.nozzleDiameter ??
item.nozzleDiameter ??
request.nozzleDiameter ??
0.4,
};
}
private normalizeQuality(value: string | undefined): string {
const normalized = String(value || 'standard').trim().toLowerCase();
if (normalized === 'high' || normalized === 'high_definition') {
return 'extra_fine';
}
return normalized || 'standard';
}
private buildEasyModePreset(quality: string): {
quality: string;
layerHeight: number;
infillDensity: number;
infillPattern: string;
nozzleDiameter: number;
} {
const normalized = this.normalizeQuality(quality);
if (normalized === 'draft') {
return {
quality: 'draft',
layerHeight: 0.28,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
if (normalized === 'extra_fine') {
return {
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'gyroid',
nozzleDiameter: 0.4,
};
}
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4,
}; };
} }
} }

View File

@@ -245,6 +245,10 @@
<span <span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span >{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
> >
<span>
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ itemMaterial(item) }}
</span>
<span <span
*ngIf="item.colorCode" *ngIf="item.colorCode"
class="color-dot" class="color-dot"
@@ -255,6 +259,41 @@
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | {{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g {{ item.materialGrams | number: "1.0-0" }}g
</div> </div>
<div class="item-preview" *ngIf="isCadSession() && isStlItem(item)">
<ng-container
*ngIf="previewFile(item) as itemPreview; else previewState"
>
<button
type="button"
class="preview-trigger"
(click)="openPreview(item)"
[attr.aria-label]="'CHECKOUT.PREVIEW_OPEN' | translate"
>
<div class="preview-surface">
<app-stl-viewer
[file]="itemPreview"
[height]="116"
[color]="previewColor(item)"
[borderRadius]="'var(--radius-lg)'"
></app-stl-viewer>
<span class="preview-pill">{{
"CHECKOUT.PREVIEW_OPEN" | translate
}}</span>
</div>
</button>
</ng-container>
<ng-template #previewState>
<div class="preview-state" *ngIf="isPreviewLoading(item)">
{{ "CHECKOUT.PREVIEW_LOADING" | translate }}
</div>
<div
class="preview-state preview-state-error"
*ngIf="!isPreviewLoading(item) && hasPreviewError(item)"
>
{{ "CHECKOUT.PREVIEW_UNAVAILABLE" | translate }}
</div>
</ng-template>
</div>
</div> </div>
<div class="item-price"> <div class="item-price">
<span class="item-total-price"> <span class="item-total-price">
@@ -302,3 +341,30 @@
</div> </div>
</div> </div>
</div> </div>
<div
class="preview-modal-backdrop"
*ngIf="previewModalOpen()"
(click)="closePreview()"
>
<div class="preview-modal" (click)="$event.stopPropagation()">
<div class="preview-modal-header">
<h4>{{ selectedPreviewName() }}</h4>
<button
type="button"
class="preview-modal-close"
(click)="closePreview()"
[attr.aria-label]="'CHECKOUT.PREVIEW_CLOSE' | translate"
>
×
</button>
</div>
<app-stl-viewer
*ngIf="selectedPreviewFile() as preview"
[file]="preview"
[height]="460"
[color]="selectedPreviewColor()"
[borderRadius]="'var(--radius-lg)'"
></app-stl-viewer>
</div>
</div>

View File

@@ -244,6 +244,77 @@ app-toggle-selector.user-type-selector-compact {
color: var(--color-text-muted); color: var(--color-text-muted);
margin-top: 2px; margin-top: 2px;
} }
.item-preview {
margin-top: var(--space-3);
.preview-trigger {
display: block;
width: 100%;
padding: 0;
margin: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: center;
.preview-surface {
position: relative;
width: min(320px, 100%);
margin-inline: auto;
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.preview-pill {
position: absolute;
top: 8px;
right: 8px;
background: rgba(17, 24, 39, 0.84);
color: #fff;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
padding: 4px 10px;
pointer-events: none;
}
&:hover .preview-surface,
&:focus-visible .preview-surface {
border-color: var(--color-brand);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
}
}
.preview-state {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-neutral-50);
color: var(--color-text-muted);
font-size: 0.8rem;
min-height: 74px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-2);
}
.preview-state-error {
color: var(--color-danger-600, #dc2626);
}
}
} }
.item-price { .item-price {
@@ -316,6 +387,53 @@ app-toggle-selector.user-type-selector-compact {
font-weight: 500; font-weight: 500;
} }
.preview-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(12, 16, 22, 0.72);
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.preview-modal {
width: min(820px, 96vw);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background: var(--color-bg-card);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
padding: var(--space-4);
}
.preview-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
h4 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
word-break: break-word;
}
}
.preview-modal-close {
border: 1px solid var(--color-border);
background: var(--color-neutral-50);
color: var(--color-text);
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.mb-6 { .mb-6 {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }

View File

@@ -17,6 +17,7 @@ import {
ToggleOption, ToggleOption,
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; } from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@Component({ @Component({
selector: 'app-checkout', selector: 'app-checkout',
@@ -29,6 +30,7 @@ import { LanguageService } from '../../core/services/language.service';
AppButtonComponent, AppButtonComponent,
AppCardComponent, AppCardComponent,
AppToggleSelectorComponent, AppToggleSelectorComponent,
StlViewerComponent,
], ],
templateUrl: './checkout.component.html', templateUrl: './checkout.component.html',
styleUrls: ['./checkout.component.scss'], styleUrls: ['./checkout.component.scss'],
@@ -46,6 +48,13 @@ export class CheckoutComponent implements OnInit {
error: string | null = null; error: string | null = null;
isSubmitting = signal(false); // Add signal for submit state isSubmitting = signal(false); // Add signal for submit state
quoteSession = signal<any>(null); // Add signal for session details quoteSession = signal<any>(null); // Add signal for session details
previewFiles = signal<Record<string, File>>({});
previewLoading = signal<Record<string, boolean>>({});
previewErrors = signal<Record<string, boolean>>({});
previewModalOpen = signal(false);
selectedPreviewFile = signal<File | null>(null);
selectedPreviewName = signal('');
selectedPreviewColor = signal('#c9ced6');
userTypeOptions: ToggleOption[] = [ userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
@@ -153,6 +162,11 @@ export class CheckoutComponent implements OnInit {
this.quoteService.getQuoteSession(this.sessionId).subscribe({ this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => { next: (session) => {
this.quoteSession.set(session); this.quoteSession.set(session);
if (this.isCadSessionData(session)) {
this.loadStlPreviews(session);
} else {
this.resetPreviewState();
}
console.log('Loaded session:', session); console.log('Loaded session:', session);
}, },
error: (err) => { error: (err) => {
@@ -163,7 +177,7 @@ export class CheckoutComponent implements OnInit {
} }
isCadSession(): boolean { isCadSession(): boolean {
return this.quoteSession()?.session?.status === 'CAD_ACTIVE'; return this.isCadSessionData(this.quoteSession());
} }
cadRequestId(): string | null { cadRequestId(): string | null {
@@ -178,6 +192,115 @@ export class CheckoutComponent implements OnInit {
return this.quoteSession()?.cadTotalChf ?? 0; return this.quoteSession()?.cadTotalChf ?? 0;
} }
itemMaterial(item: any): string {
return String(
item?.materialCode ?? this.quoteSession()?.session?.materialCode ?? '-',
);
}
isStlItem(item: any): boolean {
const name = String(item?.originalFilename ?? '').toLowerCase();
return name.endsWith('.stl');
}
previewFile(item: any): File | null {
const id = String(item?.id ?? '');
if (!id) {
return null;
}
return this.previewFiles()[id] ?? null;
}
previewColor(item: any): string {
const raw = String(item?.colorCode ?? '').trim();
return raw || '#c9ced6';
}
isPreviewLoading(item: any): boolean {
const id = String(item?.id ?? '');
if (!id) {
return false;
}
return !!this.previewLoading()[id];
}
hasPreviewError(item: any): boolean {
const id = String(item?.id ?? '');
if (!id) {
return false;
}
return !!this.previewErrors()[id];
}
openPreview(item: any): void {
const file = this.previewFile(item);
if (!file) {
return;
}
this.selectedPreviewFile.set(file);
this.selectedPreviewName.set(String(item?.originalFilename ?? file.name));
this.selectedPreviewColor.set(this.previewColor(item));
this.previewModalOpen.set(true);
}
closePreview(): void {
this.previewModalOpen.set(false);
this.selectedPreviewFile.set(null);
this.selectedPreviewName.set('');
this.selectedPreviewColor.set('#c9ced6');
}
private loadStlPreviews(session: any): void {
if (
!this.sessionId ||
!this.isCadSessionData(session) ||
!Array.isArray(session?.items)
) {
return;
}
for (const item of session.items) {
if (!this.isStlItem(item)) {
continue;
}
const id = String(item?.id ?? '');
if (!id || this.previewFiles()[id] || this.previewLoading()[id]) {
continue;
}
this.previewLoading.update((prev) => ({ ...prev, [id]: true }));
this.previewErrors.update((prev) => ({ ...prev, [id]: false }));
this.quoteService.getLineItemStlPreview(this.sessionId, id).subscribe({
next: (blob) => {
const originalName = String(item?.originalFilename ?? `${id}.stl`);
const stlName = originalName.toLowerCase().endsWith('.stl')
? originalName
: `${originalName}.stl`;
const previewFile = new File([blob], stlName, { type: 'model/stl' });
this.previewFiles.update((prev) => ({ ...prev, [id]: previewFile }));
this.previewLoading.update((prev) => ({ ...prev, [id]: false }));
},
error: () => {
this.previewErrors.update((prev) => ({ ...prev, [id]: true }));
this.previewLoading.update((prev) => ({ ...prev, [id]: false }));
},
});
}
}
private isCadSessionData(session: any): boolean {
return session?.session?.status === 'CAD_ACTIVE';
}
private resetPreviewState(): void {
this.previewFiles.set({});
this.previewLoading.set({});
this.previewErrors.set({});
this.closePreview();
}
onSubmit() { onSubmit() {
if (this.checkoutForm.invalid) { if (this.checkoutForm.invalid) {
return; return;

View File

@@ -5,5 +5,10 @@ export const CONTACT_ROUTES: Routes = [
path: '', path: '',
loadComponent: () => loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent), import('./contact-page.component').then((m) => m.ContactPageComponent),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
},
}, },
]; ];

View File

@@ -37,28 +37,40 @@
<div class="cap-cards"> <div class="cap-cards">
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/prototipi.jpg" alt="" /> <img
src="assets/images/home/prototipi.jpg"
[attr.alt]="'HOME.CAP_1_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/original-vs-3dprinted.jpg" alt="" /> <img
src="assets/images/home/original-vs-3dprinted.jpg"
[attr.alt]="'HOME.CAP_2_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/serie.jpg" alt="" /> <img
src="assets/images/home/serie.jpg"
[attr.alt]="'HOME.CAP_3_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/cad.jpg" alt="" /> <img
src="assets/images/home/cad.jpg"
[attr.alt]="'HOME.CAP_4_TITLE' | translate"
/>
</div> </div>
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3> <h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p> <p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>

View File

@@ -5,10 +5,20 @@ export const LEGAL_ROUTES: Routes = [
path: 'privacy', path: 'privacy',
loadComponent: () => loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent), import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
data: {
seoTitle: 'Privacy Policy | 3D fab',
seoDescription:
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
},
}, },
{ {
path: 'terms', path: 'terms',
loadComponent: () => loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent), import('./terms/terms.component').then((m) => m.TermsComponent),
data: {
seoTitle: 'Termini e condizioni | 3D fab',
seoDescription:
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
},
}, },
]; ];

View File

@@ -3,6 +3,22 @@ import { ShopPageComponent } from './shop-page.component';
import { ProductDetailComponent } from './product-detail.component'; import { ProductDetailComponent } from './product-detail.component';
export const SHOP_ROUTES: Routes = [ export const SHOP_ROUTES: Routes = [
{ path: '', component: ShopPageComponent }, {
{ path: ':id', component: ProductDetailComponent }, path: '',
component: ShopPageComponent,
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.',
seoRobots: 'noindex, nofollow',
},
},
{
path: ':id',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoRobots: 'noindex, nofollow',
},
},
]; ];

View File

@@ -1,4 +1,9 @@
<div class="viewer-container" #rendererContainer> <div
class="viewer-container"
#rendererContainer
[style.height.px]="height"
[style.border-radius]="borderRadius"
>
@if (loading) { @if (loading) {
<div class="loading-overlay"> <div class="loading-overlay">
<div class="spinner"></div> <div class="spinner"></div>

View File

@@ -26,6 +26,8 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
@Input() file: File | null = null; @Input() file: File | null = null;
@Input() color: string = '#facf0a'; // Default Brand Color @Input() color: string = '#facf0a'; // Default Brand Color
@Input() height = 300;
@Input() borderRadius = 'var(--radius-lg)';
@ViewChild('rendererContainer', { static: true }) @ViewChild('rendererContainer', { static: true })
rendererContainer!: ElementRef; rendererContainer!: ElementRef;
@@ -176,7 +178,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
this.scene.add(this.currentMesh); this.scene.add(this.currentMesh);
// Adjust camera to fit object // Adjust camera to fit object and keep it visually centered
const maxDim = Math.max(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z);
const fov = this.camera.fov * (Math.PI / 180); const fov = this.camera.fov * (Math.PI / 180);
@@ -184,7 +186,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.72; cameraZ *= 1.72;
this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); this.camera.position.set(
cameraZ * 0.68,
cameraZ * 0.62,
cameraZ * 1.08,
);
this.controls.target.set(0, 0, 0);
this.camera.lookAt(0, 0, 0); this.camera.lookAt(0, 0, 0);
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
this.controls.update(); this.controls.update();

View File

@@ -402,8 +402,14 @@
"SETUP_FEE": "Einrichtungskosten", "SETUP_FEE": "Einrichtungskosten",
"TOTAL": "Gesamt", "TOTAL": "Gesamt",
"QTY": "Menge", "QTY": "Menge",
"MATERIAL": "Material",
"PER_PIECE": "pro Stück", "PER_PIECE": "pro Stück",
"SHIPPING": "Versand (CH)", "SHIPPING": "Versand (CH)",
"PREVIEW_LOADING": "3D-Vorschau wird geladen...",
"PREVIEW_UNAVAILABLE": "Vorschau nicht verfügbar",
"PREVIEW_OPEN": "3D öffnen",
"PREVIEW_ENLARGE": "Klicken Sie auf die Vorschau, um das 3D-Modell zu öffnen",
"PREVIEW_CLOSE": "Vorschau schließen",
"ERR_NO_SESSION_START": "Keine aktive Sitzung gefunden. Bitte starten Sie ein neues Angebot.", "ERR_NO_SESSION_START": "Keine aktive Sitzung gefunden. Bitte starten Sie ein neues Angebot.",
"ERR_LOAD_SESSION": "Sitzungsdetails konnten nicht geladen werden. Bitte erneut versuchen.", "ERR_LOAD_SESSION": "Sitzungsdetails konnten nicht geladen werden. Bitte erneut versuchen.",
"ERR_NO_SESSION_CREATE_ORDER": "Keine aktive Sitzung gefunden. Bestellung kann nicht erstellt werden.", "ERR_NO_SESSION_CREATE_ORDER": "Keine aktive Sitzung gefunden. Bestellung kann nicht erstellt werden.",

View File

@@ -402,8 +402,14 @@
"SETUP_FEE": "Setup Fee", "SETUP_FEE": "Setup Fee",
"TOTAL": "Total", "TOTAL": "Total",
"QTY": "Qty", "QTY": "Qty",
"MATERIAL": "Material",
"PER_PIECE": "per piece", "PER_PIECE": "per piece",
"SHIPPING": "Shipping", "SHIPPING": "Shipping",
"PREVIEW_LOADING": "Loading 3D preview...",
"PREVIEW_UNAVAILABLE": "Preview not available",
"PREVIEW_OPEN": "Open 3D",
"PREVIEW_ENLARGE": "Click the preview to open the 3D model",
"PREVIEW_CLOSE": "Close preview",
"ERR_NO_SESSION_START": "No active session found. Please start a new quote.", "ERR_NO_SESSION_START": "No active session found. Please start a new quote.",
"ERR_LOAD_SESSION": "Failed to load session details. Please try again.", "ERR_LOAD_SESSION": "Failed to load session details. Please try again.",
"ERR_NO_SESSION_CREATE_ORDER": "No active session found. Cannot create order.", "ERR_NO_SESSION_CREATE_ORDER": "No active session found. Cannot create order.",

View File

@@ -459,8 +459,14 @@
"SETUP_FEE": "Coût de setup", "SETUP_FEE": "Coût de setup",
"TOTAL": "Total", "TOTAL": "Total",
"QTY": "Qté", "QTY": "Qté",
"MATERIAL": "Matériau",
"PER_PIECE": "par pièce", "PER_PIECE": "par pièce",
"SHIPPING": "Expédition (CH)", "SHIPPING": "Expédition (CH)",
"PREVIEW_LOADING": "Chargement de l'aperçu 3D...",
"PREVIEW_UNAVAILABLE": "Aperçu non disponible",
"PREVIEW_OPEN": "Ouvrir 3D",
"PREVIEW_ENLARGE": "Cliquez sur l'aperçu pour ouvrir le modèle 3D",
"PREVIEW_CLOSE": "Fermer l'aperçu",
"INVALID_EMAIL": "E-mail invalide", "INVALID_EMAIL": "E-mail invalide",
"COMPANY_OPTIONAL": "Nom de l'entreprise (Optionnel)", "COMPANY_OPTIONAL": "Nom de l'entreprise (Optionnel)",
"REF_PERSON_OPTIONAL": "Personne de référence (Optionnel)", "REF_PERSON_OPTIONAL": "Personne de référence (Optionnel)",

View File

@@ -459,8 +459,14 @@
"SETUP_FEE": "Costo di Avvio", "SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale", "TOTAL": "Totale",
"QTY": "Qtà", "QTY": "Qtà",
"MATERIAL": "Materiale",
"PER_PIECE": "al pezzo", "PER_PIECE": "al pezzo",
"SHIPPING": "Spedizione (CH)", "SHIPPING": "Spedizione (CH)",
"PREVIEW_LOADING": "Caricamento anteprima 3D...",
"PREVIEW_UNAVAILABLE": "Anteprima non disponibile",
"PREVIEW_OPEN": "Apri 3D",
"PREVIEW_ENLARGE": "Clicca sull'anteprima per aprire il modello 3D",
"PREVIEW_CLOSE": "Chiudi anteprima",
"INVALID_EMAIL": "Email non valida", "INVALID_EMAIL": "Email non valida",
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)", "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)",

View File

@@ -2,8 +2,12 @@
<html lang="it"> <html lang="it">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="robots" content="noindex, nofollow" /> <title>3D fab | Stampa 3D su misura</title>
<title>3D fab</title> <meta
name="description"
content="Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi."
/>
<meta name="robots" content="index, follow" />
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png" /> <link rel="icon" type="image/png" href="assets/images/Fav-icon.png" />

View File

@@ -0,0 +1,123 @@
# Security Best Practices Report
## Executive summary
Revisione sicurezza del progetto `print-calculator` (backend Spring Boot Java + frontend Angular/TypeScript) con focus su autenticazione/autorizzazione, esposizione dati, upload file, hardening e resilienza.
Risultato: **6 finding** totali.
- **Critical**: 1
- **High**: 3
- **Medium**: 2
Rischio principale: API pubbliche basate su UUID senza controllo di ownership/token, che consentono lettura PII e azioni di business su ordini.
## Scope e metodo
- Codice analizzato: backend (`backend/src/main/java`, `backend/src/main/resources`), frontend (`frontend/src/app`), config deploy (`deploy/`, `docker-compose*.yml`).
- Riferimenti skill usati: `javascript-general-web-frontend-security.md`.
- Nota: non è presente un riferimento specifico Java/Spring nel set dello skill; per il backend sono state applicate best practice consolidate Spring/security engineering.
## Critical findings
### SBP-001 - Broken access control su API ordine pubbliche (lettura PII + azioni stato)
- **Severity**: Critical
- **Impatto**: Chiunque ottenga un `orderId` può leggere dati personali ordine e invocare operazioni di business senza autenticazione.
- **Evidenze**:
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`).
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:131` (`GET /api/orders/{orderId}`).
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:141` (`POST /api/orders/{orderId}/payments/report`).
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:93` (`POST /api/orders/{orderId}/items/{orderItemId}/file`).
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:295`-`337` (PII completa nel DTO: email, telefono, indirizzi billing/shipping).
- `backend/src/main/java/com/printcalculator/service/PaymentService.java:53`-`75` (cambio stato pagamento a `REPORTED`).
- **Rischio tecnico**:
- Assenza di autenticazione/authorization applicativa su endpoint ordine.
- Modello “capability by UUID” senza token secondario, expiry o binding utente.
- **Fix raccomandato**:
- Introdurre un `order_access_token` random ad alta entropia (>=128 bit), memorizzato hashato e richiesto sugli endpoint pubblici ordine.
- Separare endpoint pubblici (minimo set dati) da endpoint interni/admin.
- Rimuovere `orderItemId` e dettagli sensibili dal DTO pubblico, o usare URL firmate a scadenza per upload/download.
- Valutare auth customer leggera (magic link OTP) per consultazione/modifica ordine.
## High findings
### SBP-002 - Esposizione PII su endpoint pubblico custom quote request
- **Severity**: High
- **Evidenze**:
- `backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java:188`-`193` (`GET /api/custom-quote-requests/{id}` senza auth).
- `backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java:24`-`40` (campi PII e messaggio cliente).
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`).
- **Rischio tecnico**:
- Endpoint “lookup by UUID” ritorna oggetto completo con dati personali.
- **Fix raccomandato**:
- Proteggere endpoint con token di accesso separato per richiesta (non solo UUID).
- Restituire una vista redatta/minimale per endpoint pubblici.
- Se endpoint non usato dal frontend, rimuoverlo.
### SBP-003 - Antivirus in fail-open + default scanner disattivato
- **Severity**: High
- **Evidenze**:
- `backend/src/main/resources/application.properties:27` (`clamav.enabled=${CLAMAV_ENABLED:false}`).
- `backend/src/main/java/com/printcalculator/service/ClamAVService.java:42`-`43` (scanner disabilitato => ritorna `true`).
- `backend/src/main/java/com/printcalculator/service/ClamAVService.java:54`-`61` (errori scanner => `FAIL-OPEN`).
- `backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java:59`-`60` (eccezioni scanner ignorate, file mantenuto).
- **Rischio tecnico**:
- File malevoli possono essere accettati quando scanner è down/non configurato.
- **Fix raccomandato**:
- Policy fail-closed in ambienti non-dev (`reject on scan error`).
- Rendere `CLAMAV_ENABLED=true` default in deploy runtime e bloccare startup se scanner richiesto ma non raggiungibile.
- Telemetria/alerting su scan bypass e failure rate.
### SBP-004 - Endpoint costosi esposti senza throttling/rate limit (DoS applicativo)
- **Severity**: High
- **Evidenze**:
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (endpoint pubblici permessi globalmente).
- `backend/src/main/java/com/printcalculator/controller/QuoteController.java:38`-`39` (`POST /api/quote` pubblico).
- `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:114`-`120` (`POST /api/quote-sessions/{id}/line-items` pubblico).
- `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:228`-`235` (invocazione slicing).
- `backend/src/main/java/com/printcalculator/service/SlicerService.java:156`-`163` (job fino a 5 minuti).
- **Rischio tecnico**:
- Upload/slicing massivo può saturare CPU, I/O e worker thread.
- **Fix raccomandato**:
- Rate limiting per IP/fingerprint/session (anche lato reverse proxy).
- Coda asincrona con limiti di concorrenza e timeout più stretti.
- Quote per utente/sessione e limite richieste per finestra temporale.
- CAPTCHA o proof-of-work per endpoint anonimi ad alto costo.
## Medium findings
### SBP-005 - Secret/default credenziali deboli nel codice di configurazione
- **Severity**: Medium
- **Evidenze**:
- `backend/src/main/resources/application.properties:7` (`DB_PASSWORD` fallback `printcalc_secret`).
- `backend/src/main/resources/application-local.properties:7`-`8` (admin password/secret hardcoded per profilo local).
- **Rischio tecnico**:
- In caso di misconfigurazione ambientale o uso improprio profilo, vengono usati valori prevedibili.
- **Fix raccomandato**:
- Rimuovere fallback sensibili e rendere obbligatori i secret a startup.
- Spostare credenziali locali in file non versionato (`.env.local`, `.gitignore`) con template placeholder.
- Policy di secret rotation periodica.
### SBP-006 - CSRF disabilitato globalmente con autenticazione admin basata su cookie
- **Severity**: Medium
- **Evidenze**:
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:24` (CSRF disabilitato globalmente).
- `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:129`-`136` (cookie sessione admin).
- `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:133`-`134` (`Secure` + `SameSite=Strict` presenti, mitigazione parziale).
- **Rischio tecnico**:
- Con auth cookie-based, la protezione CSRF andrebbe mantenuta sugli endpoint state-changing admin; `SameSite=Strict` riduce ma non elimina tutti i vettori.
- **Fix raccomandato**:
- Riabilitare CSRF almeno su `/api/admin/**` e usare token CSRF (double-submit o synchronizer token).
- Mantenere `SameSite=Strict` come difesa aggiuntiva.
## Note e assunzioni
- Alcuni endpoint pubblici sembrano progettati come flusso anonimo customer; il finding resta valido perché manca una seconda prova di possesso oltre allUUID.
- Non è stata eseguita una DAST esterna o pentest black-box; analisi effettuata su codice statico e configurazioni nel repository.