From fe3951b6c39c8df04e84a6d76bc9b81e3da94885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 15:43:37 +0100 Subject: [PATCH 01/10] feat(front-end): calculator improvements --- .../controller/OrderController.java | 6 + .../controller/QuoteSessionController.java | 34 ++ .../admin/AdminOrderController.java | 6 + .../com/printcalculator/dto/OrderItemDto.java | 24 ++ .../com/printcalculator/entity/OrderItem.java | 66 ++++ .../printcalculator/entity/QuoteLineItem.java | 77 ++++ .../printcalculator/service/OrderService.java | 6 + .../pages/admin-dashboard.component.html | 19 +- .../admin/pages/admin-sessions.component.html | 9 + .../services/admin-operations.service.ts | 8 + .../admin/services/admin-orders.service.ts | 6 + .../calculator/calculator-page.component.html | 2 + .../calculator/calculator-page.component.ts | 285 ++++++++++---- .../quote-result/quote-result.component.html | 6 + .../quote-result/quote-result.component.scss | 8 + .../quote-result/quote-result.component.ts | 14 + .../upload-form/upload-form.component.html | 11 +- .../upload-form/upload-form.component.scss | 6 + .../upload-form/upload-form.component.ts | 347 +++++++++++++++++- .../services/quote-estimator.service.ts | 32 +- .../features/checkout/checkout.component.html | 6 +- .../features/checkout/checkout.component.ts | 31 +- frontend/src/assets/i18n/de.json | 1 + frontend/src/assets/i18n/en.json | 1 + frontend/src/assets/i18n/fr.json | 1 + frontend/src/assets/i18n/it.json | 1 + 26 files changed, 901 insertions(+), 112 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index eb11a76..f83cf59 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -353,6 +353,12 @@ public class OrderController { idto.setOriginalFilename(i.getOriginalFilename()); idto.setMaterialCode(i.getMaterialCode()); 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.setPrintTimeSeconds(i.getPrintTimeSeconds()); idto.setMaterialGrams(i.getMaterialGrams()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 803a045..015cb8e 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -268,6 +268,15 @@ public class QuoteSessionController { item.setQuantity(1); item.setColorCode(selectedVariant.getColorName()); item.setFilamentVariant(selectedVariant); + item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null + ? selectedVariant.getFilamentMaterialType().getMaterialCode() + : normalizeRequestedMaterialCode(settings.getMaterial())); + item.setQuality(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"); // or CALCULATED item.setPrintTimeSeconds((int) stats.printTimeSeconds()); @@ -324,6 +333,8 @@ public class QuoteSessionController { settings.setInfillDensity(15.0); settings.setInfillPattern("grid"); break; + case "extra_fine": + case "high_definition": case "high": settings.setLayerHeight(0.12); settings.setInfillDensity(20.0); @@ -504,6 +515,13 @@ public class QuoteSessionController { 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", extractConvertedStoredPath(item)); @@ -667,4 +685,20 @@ public class QuoteSessionController { String path = String.valueOf(converted).trim(); return path.isEmpty() ? null : path; } + + private String resolveQuality(com.printcalculator.dto.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"; + } } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index c41e8c1..e7140ef 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -277,6 +277,12 @@ public class AdminOrderController { idto.setOriginalFilename(i.getOriginalFilename()); idto.setMaterialCode(i.getMaterialCode()); 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.setPrintTimeSeconds(i.getPrintTimeSeconds()); idto.setMaterialGrams(i.getMaterialGrams()); diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index d31d208..b098507 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -8,6 +8,12 @@ public class OrderItemDto { private String originalFilename; private String materialCode; 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 printTimeSeconds; private BigDecimal materialGrams; @@ -27,6 +33,24 @@ public class OrderItemDto { public String getColorCode() { return 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 void setQuantity(Integer quantity) { this.quantity = quantity; } diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index e5d6f65..b77573d 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -44,6 +44,24 @@ public class OrderItem { @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) 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) @JoinColumn(name = "filament_variant_id") private FilamentVariant filamentVariant; @@ -162,6 +180,54 @@ public class OrderItem { 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() { return filamentVariant; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index 321c705..c55f446 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -45,6 +45,27 @@ public class QuoteLineItem { @com.fasterxml.jackson.annotation.JsonIgnore 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 = 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; + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) private BigDecimal boundingBoxXMm; @@ -137,6 +158,62 @@ public class QuoteLineItem { 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() { return boundingBoxXMm; } diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index f42b17c..b8efcf3 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -182,6 +182,12 @@ public class OrderService { } else { 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; if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index a8696f9..b3f156b 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -192,13 +192,18 @@ {{ item.originalFilename }}

- Qta: {{ item.quantity }} | Colore: + Qta: {{ item.quantity }} | Materiale: + {{ item.materialCode || "-" }} | Colore: {{ item.colorCode || "-" }} + | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: + {{ item.layerHeightMm ?? "-" }} mm | Infill: + {{ item.infillPercent ?? "-" }}% | Supporti: + {{ item.supportsEnabled ? "Sì" : "No" }} | Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

@@ -273,17 +278,15 @@ -

Colori file

+

Parametri per file

{{ item.originalFilename }} - - {{ item.colorCode || "-" }} + {{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm + | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% + | {{ item.infillPattern || "-" }} | + {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html index 48492ef..48ee86d 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -126,6 +126,7 @@ Qta Tempo Materiale + Scelte utente Stato Prezzo unit. @@ -142,6 +143,14 @@ : "-" }} + + {{ item.materialCode || "-" }} | + {{ item.nozzleDiameterMm ?? "-" }} mm | + {{ item.layerHeightMm ?? "-" }} mm | + {{ item.infillPercent ?? "-" }}% | + {{ item.infillPattern || "-" }} | + {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} + {{ item.status }} {{ item.unitPriceChf | currency: "CHF" }} diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts index 2df5333..4f21cf5 100644 --- a/frontend/src/app/features/admin/services/admin-operations.service.ts +++ b/frontend/src/app/features/admin/services/admin-operations.service.ts @@ -127,7 +127,15 @@ export interface AdminQuoteSessionDetailItem { quantity: number; printTimeSeconds?: number; materialGrams?: number; + materialCode?: string; + quality?: string; + nozzleDiameterMm?: number; + layerHeightMm?: number; + infillPercent?: number; + infillPattern?: string; + supportsEnabled?: boolean; colorCode?: string; + filamentVariantId?: number; status: string; unitPriceChf: number; } diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 39225fe..395010c 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -8,6 +8,12 @@ export interface AdminOrderItem { originalFilename: string; materialCode: string; colorCode: string; + quality?: string; + nozzleDiameterMm?: number; + layerHeightMm?: number; + infillPercent?: number; + infillPattern?: string; + supportsEnabled?: boolean; quantity: number; printTimeSeconds: number; materialGrams: number; diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 8aab319..14c4970 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -47,6 +47,7 @@ (submitRequest)="onCalculate($event)" (itemQuantityChange)="onUploadItemQuantityChange($event)" (printSettingsChange)="onUploadPrintSettingsChange($event)" + (itemSettingsDiffChange)="onItemSettingsDiffChange($event)" > @@ -67,6 +68,7 @@ + >({}); + private baselinePrintSettings: TrackedPrintSettings | null = null; + private baselineItemSettingsByFileName = new Map(); @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; @@ -115,7 +121,12 @@ export class CalculatorPageComponent implements OnInit { 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'; this.cadSessionLocked.set(isCadSession); this.step.set('quote'); @@ -188,23 +199,33 @@ export class CalculatorPageComponent implements OnInit { }); 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) => { - // Assuming index matches. - // Need to be careful if items order changed, but usually ID sort or insert order. - if (item.colorCode) { - this.uploadForm.updateItemColor(index, { - colorName: item.colorCode, - filamentVariantId: item.filamentVariantId, - }); - } + items.forEach((item, index) => { + const tracked = this.toTrackedSettingsFromSessionItem( + 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) { + this.uploadForm.updateItemColor(index, { + colorName: item.colorCode, + filamentVariantId: item.filamentVariantId, }); } }); + + const selected = this.uploadForm.selectedFile(); + if (selected) { + this.uploadForm.selectFile(selected); + } } this.loading.set(false); }, @@ -254,7 +275,10 @@ export class CalculatorPageComponent implements OnInit { this.errorKey.set('CALC.ERROR_GENERIC'); 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.uploadProgress.set(100); this.step.set('quote'); @@ -395,7 +419,12 @@ export class CalculatorPageComponent implements OnInit { this.step.set('upload'); 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.orderSuccess.set(false); this.switchMode('easy'); // Reset to default and sync URL @@ -403,21 +432,16 @@ export class CalculatorPageComponent implements OnInit { private currentRequest: QuoteRequest | null = null; - onUploadPrintSettingsChange(settings: { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - }) { + onUploadPrintSettingsChange(_: TrackedPrintSettings) { + void _; if (!this.result()) return; - if (!this.baselinePrintSettings) return; - this.requiresRecalculation.set( - !this.sameTrackedSettings(this.baselinePrintSettings, settings), - ); + this.refreshRecalculationRequirement(); + } + + onItemSettingsDiffChange( + diffByFileName: Record, + ) { + this.itemSettingsDiffByFileName.set(diffByFileName || {}); } onConsult() { @@ -478,7 +502,12 @@ export class CalculatorPageComponent implements OnInit { this.error.set(true); 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 { @@ -499,16 +528,7 @@ export class CalculatorPageComponent implements OnInit { }); } - private toTrackedSettingsFromRequest(req: QuoteRequest): { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - } { + private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings { return { mode: req.mode, material: this.normalizeString(req.material || 'PLA'), @@ -521,16 +541,37 @@ export class CalculatorPageComponent implements OnInit { }; } - private toTrackedSettingsFromSession(session: any): { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - } { + 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(), @@ -545,27 +586,111 @@ export class CalculatorPageComponent implements OnInit { }; } + 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 { + const map = new Map(); + 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 { + const map = new Map(); + 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: { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - }, - b: { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - }, + a: TrackedPrintSettings, + b: TrackedPrintSettings, ): boolean { return ( a.mode === b.mode && @@ -583,6 +708,10 @@ export class CalculatorPageComponent implements OnInit { ); } + private normalizeFileName(fileName: string): string { + return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; + } + private normalizeString(value: string): string { return String(value || '') .trim() diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 2c81b84..67e5618 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -63,6 +63,12 @@ {{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitWeight | number: "1.0-0" }}g + @if (getItemDifferenceLabel(item.fileName)) { + | + + {{ getItemDifferenceLabel(item.fileName) }} + + } diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index b4e94b2..8218937 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -41,6 +41,14 @@ overflow: hidden; text-overflow: ellipsis; } + +.item-settings-diff { + margin-left: 2px; + font-size: 0.78rem; + font-weight: 600; + color: #8a6d1f; + white-space: normal; +} .file-details { font-size: 0.8rem; color: var(--color-text-muted); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index ce0df34..2aad331 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -36,6 +36,9 @@ export class QuoteResultComponent implements OnDestroy { result = input.required(); recalculationRequired = input(false); + itemSettingsDiffByFileName = input>( + {}, + ); consult = output(); proceed = output(); itemChange = output<{ @@ -185,4 +188,15 @@ export class QuoteResultComponent implements OnDestroy { this.quantityTimers.forEach((timer) => clearTimeout(timer)); 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(' | '); + } } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index ec755cd..da2719b 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -63,7 +63,7 @@ @@ -145,6 +145,15 @@ } + @if (items().length > 1) { +
+ + +
+ } + @if (mode() === "advanced") { diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss index 16ac239..f5d4f4b 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss @@ -211,6 +211,12 @@ } } +.sync-all-row { + margin-top: var(--space-2); + margin-bottom: var(--space-4); + padding-top: 0; +} + /* Progress Bar */ .progress-container { margin-bottom: var(--space-3); diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 6b9d46c..b41ffcb 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -37,8 +37,25 @@ interface FormItem { quantity: number; color: string; filamentVariantId?: 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; + @Component({ selector: 'app-upload-form', standalone: true, @@ -67,6 +84,7 @@ export class UploadFormComponent implements OnInit { fileName: string; quantity: number; }>(); + itemSettingsDiffChange = output>(); printSettingsChange = output<{ mode: 'easy' | 'advanced'; material: string; @@ -108,7 +126,7 @@ export class UploadFormComponent implements OnInit { if (matCode && this.fullMaterialOptions.length > 0) { const found = this.fullMaterialOptions.find((m) => m.code === matCode); this.currentMaterialVariants.set(found ? found.variants : []); - this.syncItemVariantSelections(); + this.syncSelectedItemVariantSelection(); } else { this.currentMaterialVariants.set([]); } @@ -137,6 +155,7 @@ export class UploadFormComponent implements OnInit { constructor() { this.form = this.fb.group({ itemsTouched: [false], // Hack to track touched state for custom items list + syncAllItems: [true], material: ['', Validators.required], quality: ['', Validators.required], items: [[]], // Track items in form for validation if needed @@ -164,7 +183,9 @@ export class UploadFormComponent implements OnInit { }); this.form.valueChanges.subscribe(() => { if (this.isPatchingSettings) return; + this.syncSelectedItemSettingsFromForm(); this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); }); effect(() => { @@ -337,6 +358,7 @@ export class UploadFormComponent implements OnInit { quantity: 1, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, + printSettings: this.getCurrentItemPrintSettings(), }); } } @@ -349,7 +371,8 @@ export class UploadFormComponent implements OnInit { this.items.update((current) => [...current, ...validItems]); this.form.get('itemsTouched')?.setValue(true); // Auto select last added - this.selectedFile.set(validItems[validItems.length - 1].file); + this.selectFile(validItems[validItems.length - 1].file); + this.emitItemSettingsDiffChange(); } } @@ -396,6 +419,7 @@ export class UploadFormComponent implements OnInit { } else { this.selectedFile.set(file); } + this.loadSelectedItemSettingsIntoForm(); } // Helper to get color of currently selected file @@ -451,17 +475,55 @@ export class UploadFormComponent implements OnInit { }; 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) { + let nextSelected: File | null = null; this.items.update((current) => { const updated = [...current]; const removed = updated.splice(index, 1)[0]; if (this.selectedFile() === removed.file) { - this.selectedFile.set(null); + nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null; } return updated; }); + if (nextSelected) { + this.selectFile(nextSelected); + } else if (this.items().length === 0) { + this.selectedFile.set(null); + } + this.emitItemSettingsDiffChange(); } setFiles(files: File[]) { @@ -474,6 +536,7 @@ export class UploadFormComponent implements OnInit { quantity: 1, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, + printSettings: this.getCurrentItemPrintSettings(), }); } @@ -481,7 +544,8 @@ export class UploadFormComponent implements OnInit { this.items.set(validItems); this.form.get('itemsTouched')?.setValue(true); // Auto select last added - this.selectedFile.set(validItems[validItems.length - 1].file); + this.selectFile(validItems[validItems.length - 1].file); + this.emitItemSettingsDiffChange(); } } @@ -510,25 +574,48 @@ export class UploadFormComponent implements OnInit { 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(); if (!vars || vars.length === 0) { return; } + const selected = this.selectedFile(); + if (!selected) { + return; + } + const fallback = vars.find((v) => !v.isOutOfStock) || vars[0]; this.items.update((current) => current.map((item) => { + if (item.file !== selected) { + return item; + } const byId = item.filamentVariantId != null ? vars.find((v) => v.id === item.filamentVariantId) : null; const byColor = vars.find((v) => v.colorName === item.color); - const selected = byId || byColor || fallback; + const selectedVariant = byId || byColor || fallback; return { ...item, - color: selected.colorName, - filamentVariantId: selected.id, + color: selectedVariant.colorName, + filamentVariantId: selectedVariant.id, }; }), ); @@ -592,7 +679,7 @@ export class UploadFormComponent implements OnInit { ); this.submitRequest.emit({ ...this.form.getRawValue(), - items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite + items: this.toQuoteRequestItems(), // Include per-item print settings overrides mode: this.mode(), }); } else { @@ -666,7 +753,7 @@ export class UploadFormComponent implements OnInit { if (this.items().length === 0) return null; const raw = this.form.getRawValue(); return { - items: this.items(), + items: this.toQuoteRequestItems(), material: raw.material, quality: raw.quality, notes: raw.notes, @@ -706,8 +793,248 @@ export class UploadFormComponent implements OnInit { this.printSettingsChange.emit(this.getCurrentPrintSettings()); } + private loadSelectedItemSettingsIntoForm(): void { + const selected = this.selectedFile(); + if (!selected) return; + const item = this.items().find((current) => current.file === selected); + if (!item) return; + + this.isPatchingSettings = true; + this.form.patchValue( + { + material: item.printSettings.material, + quality: item.printSettings.quality, + nozzleDiameter: item.printSettings.nozzleDiameter, + layerHeight: item.printSettings.layerHeight, + infillDensity: item.printSettings.infillDensity, + infillPattern: item.printSettings.infillPattern, + supportEnabled: item.printSettings.supportEnabled, + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + this.updateLayerHeightOptionsForNozzle( + item.printSettings.nozzleDiameter, + true, + ); + this.updateVariants(); + } + + private syncSelectedItemSettingsFromForm(): void { + const currentSettings = this.getCurrentItemPrintSettings(); + + if (this.shouldApplySettingsToAllItems()) { + this.applyCurrentSettingsToAllItems(currentSettings); + return; + } + + const selected = this.selectedFile(); + if (!selected) return; + + this.items.update((current) => + current.map((item) => { + if (item.file !== selected) { + return item; + } + const variants = this.getVariantsForMaterialCode(currentSettings.material); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor || fallback; + return { + ...item, + printSettings: { ...currentSettings }, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant ? selectedVariant.id : undefined, + }; + }), + ); + } + + private emitItemSettingsDiffChange(): void { + const currentItems = this.items(); + if (currentItems.length === 0) { + this.itemSettingsDiffChange.emit({}); + return; + } + + const signatureCounts = new Map(); + currentItems.forEach((item) => { + const signature = this.settingsSignature(item.printSettings); + signatureCounts.set(signature, (signatureCounts.get(signature) || 0) + 1); + }); + + let dominantSignature = ''; + let dominantCount = 0; + signatureCounts.forEach((count, signature) => { + if (count > dominantCount) { + dominantCount = count; + dominantSignature = signature; + } + }); + + const hasDominant = dominantCount > 1; + const dominantSettings = hasDominant + ? currentItems.find( + (item) => + this.settingsSignature(item.printSettings) === dominantSignature, + )?.printSettings + : null; + + const diffByFileName: Record = {}; + currentItems.forEach((item) => { + const differences = dominantSettings + ? this.describeSettingsDifferences(dominantSettings, item.printSettings) + : []; + diffByFileName[item.file.name] = { + differences, + }; + }); + + this.itemSettingsDiffChange.emit(diffByFileName); + } + + private sameItemPrintSettings( + a: ItemPrintSettings, + b: ItemPrintSettings, + ): boolean { + return ( + a.material.trim().toUpperCase() === b.material.trim().toUpperCase() && + a.quality.trim().toLowerCase() === b.quality.trim().toLowerCase() && + Math.abs(a.nozzleDiameter - b.nozzleDiameter) < 0.0001 && + Math.abs(a.layerHeight - b.layerHeight) < 0.0001 && + Math.abs(a.infillDensity - b.infillDensity) < 0.0001 && + a.infillPattern.trim().toLowerCase() === + b.infillPattern.trim().toLowerCase() && + Boolean(a.supportEnabled) === Boolean(b.supportEnabled) + ); + } + + private settingsSignature(settings: ItemPrintSettings): string { + return JSON.stringify({ + material: settings.material.trim().toUpperCase(), + quality: settings.quality.trim().toLowerCase(), + nozzleDiameter: Number(settings.nozzleDiameter.toFixed(2)), + layerHeight: Number(settings.layerHeight.toFixed(3)), + infillDensity: Number(settings.infillDensity.toFixed(2)), + infillPattern: settings.infillPattern.trim().toLowerCase(), + supportEnabled: Boolean(settings.supportEnabled), + }); + } + + private describeSettingsDifferences( + baseline: ItemPrintSettings, + current: ItemPrintSettings, + ): string[] { + if (this.sameItemPrintSettings(baseline, current)) { + return []; + } + + const differences: string[] = []; + if (baseline.material.trim().toUpperCase() !== current.material.trim().toUpperCase()) { + differences.push(`${current.material}`); + } + if (baseline.quality.trim().toLowerCase() !== current.quality.trim().toLowerCase()) { + differences.push(`Qualita: ${current.quality}`); + } + if (Math.abs(baseline.nozzleDiameter - current.nozzleDiameter) >= 0.0001) { + differences.push(`Nozzle: ${current.nozzleDiameter.toFixed(1)} mm`); + } + if (Math.abs(baseline.layerHeight - current.layerHeight) >= 0.0001) { + differences.push(`Layer: ${current.layerHeight.toFixed(2)} mm`); + } + if (Math.abs(baseline.infillDensity - current.infillDensity) >= 0.0001) { + differences.push(`Infill: ${current.infillDensity}%`); + } + if ( + baseline.infillPattern.trim().toLowerCase() !== + current.infillPattern.trim().toLowerCase() + ) { + differences.push(`Pattern: ${current.infillPattern}`); + } + if (Boolean(baseline.supportEnabled) !== Boolean(current.supportEnabled)) { + differences.push( + `Supporti: ${current.supportEnabled ? 'attivi' : 'disattivi'}`, + ); + } + return differences; + } + + private toQuoteRequestItems(): QuoteRequest['items'] { + return this.items().map((item) => ({ + file: item.file, + quantity: item.quantity, + color: item.color, + filamentVariantId: item.filamentVariantId, + material: item.printSettings.material, + quality: item.printSettings.quality, + nozzleDiameter: item.printSettings.nozzleDiameter, + layerHeight: item.printSettings.layerHeight, + infillDensity: item.printSettings.infillDensity, + infillPattern: item.printSettings.infillPattern, + supportEnabled: item.printSettings.supportEnabled, + })); + } + + private getCurrentItemPrintSettings(): ItemPrintSettings { + const settings = this.getCurrentPrintSettings(); + return { + material: settings.material, + quality: settings.quality, + nozzleDiameter: settings.nozzleDiameter, + layerHeight: settings.layerHeight, + infillDensity: settings.infillDensity, + infillPattern: settings.infillPattern, + supportEnabled: settings.supportEnabled, + }; + } + + private shouldApplySettingsToAllItems(): boolean { + return this.parseBooleanControlValue(this.form.get('syncAllItems')?.value); + } + + private applyCurrentSettingsToAllItems(currentSettings: ItemPrintSettings): void { + this.items.update((current) => + current.map((item) => { + const variants = this.getVariantsForMaterialCode(currentSettings.material); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor || fallback; + + return { + ...item, + printSettings: { ...currentSettings }, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant ? selectedVariant.id : undefined, + }; + }), + ); + } + + private parseBooleanControlValue(raw: unknown): boolean { + if (this.items().length <= 1) { + return false; + } + if (raw === true || raw === 1) { + return true; + } + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'on'; + } + return false; + } + private applySettingsLock(locked: boolean): void { const controlsToLock = [ + 'syncAllItems', 'material', 'quality', 'nozzleDiameter', diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index fed6db7..152b7f2 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -10,6 +10,13 @@ export interface QuoteRequest { quantity: number; color?: string; filamentVariantId?: number; + material?: string; + quality?: string; + nozzleDiameter?: number; + layerHeight?: number; + infillDensity?: number; + infillPattern?: string; + supportEnabled?: boolean; }[]; material: string; quality: string; @@ -150,7 +157,7 @@ export class QuoteEstimatorService { if (normalized === 'draft') { return { - quality: 'extra_fine', + quality: 'draft', layerHeight: 0.24, infillDensity: 12, infillPattern: 'grid', @@ -306,23 +313,28 @@ export class QuoteEstimatorService { request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(), - material: request.material, + material: item.material || request.material, filamentVariantId: item.filamentVariantId, - quality: easyPreset ? easyPreset.quality : request.quality, - supportsEnabled: request.supportEnabled, + quality: easyPreset + ? easyPreset.quality + : item.quality || request.quality, + supportsEnabled: + easyPreset != null + ? request.supportEnabled + : item.supportEnabled ?? request.supportEnabled, color: item.color || '#FFFFFF', layerHeight: easyPreset ? easyPreset.layerHeight - : request.layerHeight, + : item.layerHeight ?? request.layerHeight, infillDensity: easyPreset ? easyPreset.infillDensity - : request.infillDensity, + : item.infillDensity ?? request.infillDensity, infillPattern: easyPreset ? easyPreset.infillPattern - : request.infillPattern, + : item.infillPattern ?? request.infillPattern, nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter - : request.nozzleDiameter, + : item.nozzleDiameter ?? request.nozzleDiameter, }; const settingsBlob = new Blob([JSON.stringify(settings)], { @@ -477,9 +489,7 @@ export class QuoteEstimatorService { unitTime: item.printTimeSeconds, unitWeight: item.materialGrams, quantity: item.quantity, - material: session.materialCode, // Assumption: session has one material for all? or items have it? - // Backend model QuoteSession has materialCode. - // But line items might have different colors. + material: item.materialCode || session.materialCode, color: item.colorCode, filamentVariantId: item.filamentVariantId, })), diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 123df04..0a85813 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -245,6 +245,10 @@ {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + + {{ "CHECKOUT.MATERIAL" | translate }}: + {{ itemMaterial(item) }} + -
+
diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index ad95985..66e98fc 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -162,7 +162,11 @@ export class CheckoutComponent implements OnInit { this.quoteService.getQuoteSession(this.sessionId).subscribe({ next: (session) => { this.quoteSession.set(session); - this.loadStlPreviews(session); + if (this.isCadSessionData(session)) { + this.loadStlPreviews(session); + } else { + this.resetPreviewState(); + } console.log('Loaded session:', session); }, error: (err) => { @@ -173,7 +177,7 @@ export class CheckoutComponent implements OnInit { } isCadSession(): boolean { - return this.quoteSession()?.session?.status === 'CAD_ACTIVE'; + return this.isCadSessionData(this.quoteSession()); } cadRequestId(): string | null { @@ -188,6 +192,12 @@ export class CheckoutComponent implements OnInit { 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'); @@ -241,7 +251,11 @@ export class CheckoutComponent implements OnInit { } private loadStlPreviews(session: any): void { - if (!this.sessionId || !Array.isArray(session?.items)) { + if ( + !this.sessionId || + !this.isCadSessionData(session) || + !Array.isArray(session?.items) + ) { return; } @@ -276,6 +290,17 @@ export class CheckoutComponent implements OnInit { } } + 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() { if (this.checkoutForm.invalid) { return; diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index ca1275b..d5b49f4 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -402,6 +402,7 @@ "SETUP_FEE": "Einrichtungskosten", "TOTAL": "Gesamt", "QTY": "Menge", + "MATERIAL": "Material", "PER_PIECE": "pro Stück", "SHIPPING": "Versand (CH)", "PREVIEW_LOADING": "3D-Vorschau wird geladen...", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e464d13..b02eeb0 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -402,6 +402,7 @@ "SETUP_FEE": "Setup Fee", "TOTAL": "Total", "QTY": "Qty", + "MATERIAL": "Material", "PER_PIECE": "per piece", "SHIPPING": "Shipping", "PREVIEW_LOADING": "Loading 3D preview...", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 02ebd0c..7e54110 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -459,6 +459,7 @@ "SETUP_FEE": "Coût de setup", "TOTAL": "Total", "QTY": "Qté", + "MATERIAL": "Matériau", "PER_PIECE": "par pièce", "SHIPPING": "Expédition (CH)", "PREVIEW_LOADING": "Chargement de l'aperçu 3D...", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 9444cb8..4e6b8f5 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -459,6 +459,7 @@ "SETUP_FEE": "Costo di Avvio", "TOTAL": "Totale", "QTY": "Qtà", + "MATERIAL": "Materiale", "PER_PIECE": "al pezzo", "SHIPPING": "Spedizione (CH)", "PREVIEW_LOADING": "Caricamento anteprima 3D...", From 9facf05c1077a752a39de6f8ab48388669fed9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 15:44:03 +0100 Subject: [PATCH 02/10] fix(back-end): twint url --- backend/src/main/resources/application.properties | 2 +- docker-compose.deploy.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index db27cf5..0915c7e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -27,7 +27,7 @@ clamav.port=${CLAMAV_PORT:3310} clamav.enabled=${CLAMAV_ENABLED:false} # 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 spring.mail.host=${MAIL_HOST:mail.infomaniak.com} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 1a141fe..a3db5b0 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -13,6 +13,7 @@ services: - CLAMAV_HOST=${CLAMAV_HOST} - CLAMAV_PORT=${CLAMAV_PORT} - CLAMAV_ENABLED=${CLAMAV_ENABLED} + - TWINT_PAYMENT_URL=${TWINT_PAYMENT_URL:-} - MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com} - MAIL_PORT=${MAIL_PORT:-587} - MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch} From 54b50028b121e5d59b91d291d73240ad7a0e936c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 16:37:54 +0100 Subject: [PATCH 03/10] fix(back-end): path solver --- .../admin/AdminOrderController.java | 103 +++++++++++++++++- .../printcalculator/service/OrderService.java | 38 +++++-- 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 764940d..70aea94 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -7,17 +7,20 @@ import com.printcalculator.dto.OrderItemDto; import com.printcalculator.entity.Order; import com.printcalculator.entity.OrderItem; import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.PaymentService; import com.printcalculator.service.QrBillService; import com.printcalculator.service.StorageService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -32,9 +35,11 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -47,6 +52,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; @RequestMapping("/api/admin/orders") @Transactional(readOnly = true) public class AdminOrderController { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); private static final List ALLOWED_ORDER_STATUSES = List.of( "PENDING_PAYMENT", "PAID", @@ -59,6 +65,7 @@ public class AdminOrderController { private final OrderRepository orderRepo; private final OrderItemRepository orderItemRepo; private final PaymentRepository paymentRepo; + private final QuoteLineItemRepository quoteLineItemRepo; private final PaymentService paymentService; private final StorageService storageService; private final InvoicePdfRenderingService invoiceService; @@ -69,6 +76,7 @@ public class AdminOrderController { OrderRepository orderRepo, OrderItemRepository orderItemRepo, PaymentRepository paymentRepo, + QuoteLineItemRepository quoteLineItemRepo, PaymentService paymentService, StorageService storageService, InvoicePdfRenderingService invoiceService, @@ -78,6 +86,7 @@ public class AdminOrderController { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.paymentRepo = paymentRepo; + this.quoteLineItemRepo = quoteLineItemRepo; this.paymentService = paymentService; this.storageService = storageService; this.invoiceService = invoiceService; @@ -166,7 +175,7 @@ public class AdminOrderController { } try { - Resource resource = storageService.loadAsResource(safeRelativePath); + Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath); MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; if (item.getMimeType() != null && !item.getMimeType().isBlank()) { try { @@ -340,6 +349,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) { return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); } diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 3a1f606..1ab620e 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -16,6 +16,7 @@ import java.io.IOException; 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.time.OffsetDateTime; @@ -23,6 +24,7 @@ import java.util.*; @Service public class OrderService { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); private final OrderRepository orderRepo; private final OrderItemRepository orderItemRepo; @@ -210,16 +212,15 @@ public class OrderService { String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; oItem.setStoredRelativePath(relativePath); - if (qItem.getStoredPath() != null) { - try { - Path sourcePath = Paths.get(qItem.getStoredPath()); - if (Files.exists(sourcePath)) { - storageService.store(sourcePath, Paths.get(relativePath)); - oItem.setFileSizeBytes(Files.size(sourcePath)); - } - } catch (IOException e) { - e.printStackTrace(); - } + 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 { + storageService.store(sourcePath, Paths.get(relativePath)); + oItem.setFileSizeBytes(Files.size(sourcePath)); + } catch (IOException e) { + throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); } oItem = orderItemRepo.save(oItem); @@ -291,6 +292,23 @@ public class OrderService { 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) { String orderNumber = order.getOrderNumber(); if (orderNumber != null && !orderNumber.isBlank()) { From 8c6199082711e0caf1321f782881958f7c963e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 16:46:24 +0100 Subject: [PATCH 04/10] fix(tutto rotto): --- .../printcalculator/entity/QuoteLineItem.java | 66 ++ db.sql | 24 + .../quote-result/quote-result.component.html | 3 +- .../upload-form/upload-form.component.html | 269 ++++++-- .../upload-form/upload-form.component.scss | 75 +- .../upload-form/upload-form.component.ts | 652 +++++++++--------- .../services/quote-estimator.service.ts | 37 +- 7 files changed, 726 insertions(+), 400 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index c55f446..9849cab 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -66,6 +66,24 @@ public class QuoteLineItem { @Column(name = "supports_enabled") private Boolean supportsEnabled; + @Column(name = "material_code", length = Integer.MAX_VALUE) + private String materialCode; + + @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_pattern", length = Integer.MAX_VALUE) + private String infillPattern; + + @Column(name = "infill_percent") + private Integer infillPercent; + + @Column(name = "supports_enabled") + private Boolean supportsEnabled; + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) private BigDecimal boundingBoxXMm; @@ -214,6 +232,54 @@ public class QuoteLineItem { this.supportsEnabled = supportsEnabled; } + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + 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 String getInfillPattern() { + return infillPattern; + } + + public void setInfillPattern(String infillPattern) { + this.infillPattern = infillPattern; + } + + public Integer getInfillPercent() { + return infillPercent; + } + + public void setInfillPercent(Integer infillPercent) { + this.infillPercent = infillPercent; + } + + public Boolean getSupportsEnabled() { + return supportsEnabled; + } + + public void setSupportsEnabled(Boolean supportsEnabled) { + this.supportsEnabled = supportsEnabled; + } + public BigDecimal getBoundingBoxXMm() { return boundingBoxXMm; } diff --git a/db.sql b/db.sql index ce3a171..d7fd322 100644 --- a/db.sql +++ b/db.sql @@ -660,6 +660,12 @@ CREATE TABLE IF NOT EXISTS quote_line_items quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), color_code text, -- es: white/black o codice interno 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 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 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 CREATE OR REPLACE VIEW quote_session_totals AS SELECT qs.quote_session_id, diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 67e5618..8fccbf1 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -62,7 +62,8 @@ {{ item.fileName }} {{ 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)) { | diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index da2719b..6fce66e 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -63,7 +63,7 @@ @@ -102,34 +102,153 @@ + {{ "CALC.ADD_FILES" | translate }}
- } - @if (items().length === 0 && form.get("itemsTouched")?.value) { -
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
- } - -

- {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }} - {{ - "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate - }}. -

-
- -
- @if (lockedSettings()) {

- Parametri stampa bloccati per sessione CAD: materiale, nozzle, layer, - infill e supporti sono definiti dal back-office. + {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }} + {{ + "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate + }}.

- } - + + + @if (sameSettingsForAll()) { +
+

Impostazioni globali

+ +
+ + + @if (mode() === "easy") { + + } @else { + + } +
+ + @if (mode() === "advanced") { +
+ + + +
+ +
+ + + +
+ } +
+ } @else { + @if (getSelectedItem(); as selectedItem) { +
+

+ Impostazioni file: {{ selectedItem.file.name }} +

+ +
+ + + @if (mode() === "easy") { + + } @else { + + } +
@if (mode() === "easy") { } - + @if (mode() === "advanced") { +
+ - @if (mode() === "advanced") { -
- + +
- -
+
+ -
- + +
+ } +
+ } + } + } -
- - -
-
- } + @if (items().length === 0 && form.get("itemsTouched")?.value) { +
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
+ } + +
; ReactiveFormsModule, TranslateModule, AppInputComponent, - AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, @@ -117,6 +122,7 @@ export class UploadFormComponent implements OnInit { private allLayerHeights: SimpleOption[] = []; private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; + sameSettingsForAll = signal(true); // Computed variants for valid material currentMaterialVariants = signal([]); @@ -152,6 +158,24 @@ export class UploadFormComponent implements OnInit { 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() { this.form = this.fb.group({ itemsTouched: [false], // Hack to track touched state for custom items list @@ -168,14 +192,50 @@ export class UploadFormComponent implements OnInit { supportEnabled: [false], }); - // Listen to material changes to update variants - this.form.get('material')?.valueChanges.subscribe(() => { + // Listen to material changes to update variants and propagate when "all files equal" is active. + this.form.get('material')?.valueChanges.subscribe((materialCode) => { this.updateVariants(); + if (this.sameSettingsForAll() && !this.isPatchingSettings) { + this.applyGlobalMaterialToAll(String(materialCode || 'PLA')); + } }); this.form.get('quality')?.valueChanges.subscribe((quality) => { if (this.mode() !== 'easy' || this.isPatchingSettings) return; 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; @@ -346,18 +406,26 @@ export class UploadFormComponent implements OnInit { const MAX_SIZE = 200 * 1024 * 1024; // 200MB const validItems: FormItem[] = []; let hasError = false; + const defaults = this.getCurrentGlobalItemDefaults(); for (const file of newFiles) { if (file.size > MAX_SIZE) { hasError = true; } else { - const defaultSelection = this.getDefaultVariantSelection(); + const defaultSelection = this.getDefaultVariantSelection(defaults.material); validItems.push({ file, previewFile: this.isStlFile(file) ? file : undefined, quantity: 1, + material: defaults.material, + quality: defaults.quality, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, printSettings: this.getCurrentItemPrintSettings(), }); } @@ -391,19 +459,25 @@ export class UploadFormComponent implements OnInit { this.items.update((current) => { if (index >= current.length) return current; - const updated = [...current]; - updated[index] = { ...updated[index], quantity: normalizedQty }; - return updated; + const applyToAll = this.sameSettingsForAll(); + return current.map((item, idx) => { + if (!applyToAll && idx !== index) return item; + return { ...item, quantity: normalizedQty }; + }); }); } updateItemQuantityByName(fileName: string, quantity: number) { const targetName = this.normalizeFileName(fileName); const normalizedQty = this.normalizeQuantity(quantity); + const applyToAll = this.sameSettingsForAll(); this.items.update((current) => { let matched = false; return current.map((item) => { + if (applyToAll) { + return { ...item, quantity: normalizedQty }; + } if (!matched && this.normalizeFileName(item.file.name) === targetName) { matched = true; return { ...item, quantity: normalizedQty }; @@ -429,7 +503,7 @@ export class UploadFormComponent implements OnInit { const item = this.items().find((i) => i.file === file); if (item) { - const vars = this.currentMaterialVariants(); + const vars = this.getVariantsForMaterial(item.material); if (vars && vars.length > 0) { const found = item.filamentVariantId ? vars.find((v) => v.id === item.filamentVariantId) @@ -468,11 +542,206 @@ export class UploadFormComponent implements OnInit { : newSelection.filamentVariantId; this.items.update((current) => { const updated = [...current]; - updated[index] = { - ...updated[index], - color: colorName, - filamentVariantId, - }; + const applyToAll = this.sameSettingsForAll(); + return updated.map((item, idx) => { + if (!applyToAll && idx !== index) return item; + return { + ...item, + color: colorName, + filamentVariantId, + }; + }); + }); + } + + 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 = { + 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) { + if (!Number.isInteger(index) || index < 0) return; + const normalizedPatch: Partial = { ...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(); @@ -528,15 +797,22 @@ export class UploadFormComponent implements OnInit { setFiles(files: File[]) { const validItems: FormItem[] = []; - const defaultSelection = this.getDefaultVariantSelection(); + const defaults = this.getCurrentGlobalItemDefaults(); + const defaultSelection = this.getDefaultVariantSelection(defaults.material); for (const file of files) { validItems.push({ file, previewFile: this.isStlFile(file) ? file : undefined, quantity: 1, + material: defaults.material, + quality: defaults.quality, color: defaultSelection.colorName, filamentVariantId: defaultSelection.filamentVariantId, - printSettings: this.getCurrentItemPrintSettings(), + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, }); } @@ -559,11 +835,28 @@ export class UploadFormComponent implements OnInit { }); } - private getDefaultVariantSelection(): { + private getCurrentGlobalItemDefaults(): Omit & { + 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; filamentVariantId?: number; } { - const vars = this.currentMaterialVariants(); + const vars = materialCode + ? this.getVariantsForMaterial(materialCode) + : this.currentMaterialVariants(); if (vars && vars.length > 0) { const preferred = vars.find((v) => !v.isOutOfStock) || vars[0]; return { @@ -673,13 +966,15 @@ export class UploadFormComponent implements OnInit { console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); if (this.form.valid && this.items().length > 0) { + const items = this.items(); + const firstItemMaterial = items[0]?.material; console.log( 'UploadFormComponent: Emitting submitRequest', this.form.value, ); this.submitRequest.emit({ ...this.form.getRawValue(), - items: this.toQuoteRequestItems(), // Include per-item print settings overrides + items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite mode: this.mode(), }); } else { @@ -713,325 +1008,6 @@ export class UploadFormComponent implements OnInit { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } - private updateLayerHeightOptionsForNozzle( - nozzleValue: unknown, - preserveCurrent: boolean, - ): void { - const key = this.toNozzleKey(nozzleValue); - const nozzleSpecific = this.layerHeightsByNozzle[key] || []; - const available = - nozzleSpecific.length > 0 ? nozzleSpecific : this.allLayerHeights; - this.layerHeights.set(available); - - const control = this.form.get('layerHeight'); - if (!control) return; - - const currentValue = Number(control.value); - const currentAllowed = available.some( - (option) => Math.abs(Number(option.value) - currentValue) < 0.0001, - ); - if (preserveCurrent && currentAllowed) { - return; - } - - const preferred = available.find( - (option) => Math.abs(Number(option.value) - 0.2) < 0.0001, - ); - const next = preferred ?? available[0]; - if (next) { - control.setValue(next.value, { emitEvent: false }); - } - } - - private toNozzleKey(value: unknown): string { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return ''; - return numeric.toFixed(2); - } - - getCurrentRequestDraft(): QuoteRequest | null { - if (this.items().length === 0) return null; - const raw = this.form.getRawValue(); - return { - items: this.toQuoteRequestItems(), - material: raw.material, - quality: raw.quality, - notes: raw.notes, - infillDensity: raw.infillDensity, - infillPattern: raw.infillPattern, - supportEnabled: raw.supportEnabled, - layerHeight: raw.layerHeight, - nozzleDiameter: raw.nozzleDiameter, - mode: this.mode(), - }; - } - - getCurrentPrintSettings(): { - mode: 'easy' | 'advanced'; - material: string; - quality: string; - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - supportEnabled: boolean; - } { - const raw = this.form.getRawValue(); - return { - mode: this.mode(), - material: String(raw.material || 'PLA'), - quality: String(raw.quality || 'standard'), - nozzleDiameter: Number(raw.nozzleDiameter ?? 0.4), - layerHeight: Number(raw.layerHeight ?? 0.2), - infillDensity: Number(raw.infillDensity ?? 20), - infillPattern: String(raw.infillPattern || 'grid'), - supportEnabled: Boolean(raw.supportEnabled), - }; - } - - private emitPrintSettingsChange(): void { - this.printSettingsChange.emit(this.getCurrentPrintSettings()); - } - - private loadSelectedItemSettingsIntoForm(): void { - const selected = this.selectedFile(); - if (!selected) return; - const item = this.items().find((current) => current.file === selected); - if (!item) return; - - this.isPatchingSettings = true; - this.form.patchValue( - { - material: item.printSettings.material, - quality: item.printSettings.quality, - nozzleDiameter: item.printSettings.nozzleDiameter, - layerHeight: item.printSettings.layerHeight, - infillDensity: item.printSettings.infillDensity, - infillPattern: item.printSettings.infillPattern, - supportEnabled: item.printSettings.supportEnabled, - }, - { emitEvent: false }, - ); - this.isPatchingSettings = false; - this.updateLayerHeightOptionsForNozzle( - item.printSettings.nozzleDiameter, - true, - ); - this.updateVariants(); - } - - private syncSelectedItemSettingsFromForm(): void { - const currentSettings = this.getCurrentItemPrintSettings(); - - if (this.shouldApplySettingsToAllItems()) { - this.applyCurrentSettingsToAllItems(currentSettings); - return; - } - - const selected = this.selectedFile(); - if (!selected) return; - - this.items.update((current) => - current.map((item) => { - if (item.file !== selected) { - return item; - } - const variants = this.getVariantsForMaterialCode(currentSettings.material); - const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; - const byId = - item.filamentVariantId != null - ? variants.find((v) => v.id === item.filamentVariantId) - : null; - const byColor = variants.find((v) => v.colorName === item.color); - const selectedVariant = byId || byColor || fallback; - return { - ...item, - printSettings: { ...currentSettings }, - color: selectedVariant ? selectedVariant.colorName : item.color, - filamentVariantId: selectedVariant ? selectedVariant.id : undefined, - }; - }), - ); - } - - private emitItemSettingsDiffChange(): void { - const currentItems = this.items(); - if (currentItems.length === 0) { - this.itemSettingsDiffChange.emit({}); - return; - } - - const signatureCounts = new Map(); - currentItems.forEach((item) => { - const signature = this.settingsSignature(item.printSettings); - signatureCounts.set(signature, (signatureCounts.get(signature) || 0) + 1); - }); - - let dominantSignature = ''; - let dominantCount = 0; - signatureCounts.forEach((count, signature) => { - if (count > dominantCount) { - dominantCount = count; - dominantSignature = signature; - } - }); - - const hasDominant = dominantCount > 1; - const dominantSettings = hasDominant - ? currentItems.find( - (item) => - this.settingsSignature(item.printSettings) === dominantSignature, - )?.printSettings - : null; - - const diffByFileName: Record = {}; - currentItems.forEach((item) => { - const differences = dominantSettings - ? this.describeSettingsDifferences(dominantSettings, item.printSettings) - : []; - diffByFileName[item.file.name] = { - differences, - }; - }); - - this.itemSettingsDiffChange.emit(diffByFileName); - } - - private sameItemPrintSettings( - a: ItemPrintSettings, - b: ItemPrintSettings, - ): boolean { - return ( - a.material.trim().toUpperCase() === b.material.trim().toUpperCase() && - a.quality.trim().toLowerCase() === b.quality.trim().toLowerCase() && - Math.abs(a.nozzleDiameter - b.nozzleDiameter) < 0.0001 && - Math.abs(a.layerHeight - b.layerHeight) < 0.0001 && - Math.abs(a.infillDensity - b.infillDensity) < 0.0001 && - a.infillPattern.trim().toLowerCase() === - b.infillPattern.trim().toLowerCase() && - Boolean(a.supportEnabled) === Boolean(b.supportEnabled) - ); - } - - private settingsSignature(settings: ItemPrintSettings): string { - return JSON.stringify({ - material: settings.material.trim().toUpperCase(), - quality: settings.quality.trim().toLowerCase(), - nozzleDiameter: Number(settings.nozzleDiameter.toFixed(2)), - layerHeight: Number(settings.layerHeight.toFixed(3)), - infillDensity: Number(settings.infillDensity.toFixed(2)), - infillPattern: settings.infillPattern.trim().toLowerCase(), - supportEnabled: Boolean(settings.supportEnabled), - }); - } - - private describeSettingsDifferences( - baseline: ItemPrintSettings, - current: ItemPrintSettings, - ): string[] { - if (this.sameItemPrintSettings(baseline, current)) { - return []; - } - - const differences: string[] = []; - if (baseline.material.trim().toUpperCase() !== current.material.trim().toUpperCase()) { - differences.push(`${current.material}`); - } - if (baseline.quality.trim().toLowerCase() !== current.quality.trim().toLowerCase()) { - differences.push(`Qualita: ${current.quality}`); - } - if (Math.abs(baseline.nozzleDiameter - current.nozzleDiameter) >= 0.0001) { - differences.push(`Nozzle: ${current.nozzleDiameter.toFixed(1)} mm`); - } - if (Math.abs(baseline.layerHeight - current.layerHeight) >= 0.0001) { - differences.push(`Layer: ${current.layerHeight.toFixed(2)} mm`); - } - if (Math.abs(baseline.infillDensity - current.infillDensity) >= 0.0001) { - differences.push(`Infill: ${current.infillDensity}%`); - } - if ( - baseline.infillPattern.trim().toLowerCase() !== - current.infillPattern.trim().toLowerCase() - ) { - differences.push(`Pattern: ${current.infillPattern}`); - } - if (Boolean(baseline.supportEnabled) !== Boolean(current.supportEnabled)) { - differences.push( - `Supporti: ${current.supportEnabled ? 'attivi' : 'disattivi'}`, - ); - } - return differences; - } - - private toQuoteRequestItems(): QuoteRequest['items'] { - return this.items().map((item) => ({ - file: item.file, - quantity: item.quantity, - color: item.color, - filamentVariantId: item.filamentVariantId, - material: item.printSettings.material, - quality: item.printSettings.quality, - nozzleDiameter: item.printSettings.nozzleDiameter, - layerHeight: item.printSettings.layerHeight, - infillDensity: item.printSettings.infillDensity, - infillPattern: item.printSettings.infillPattern, - supportEnabled: item.printSettings.supportEnabled, - })); - } - - private getCurrentItemPrintSettings(): ItemPrintSettings { - const settings = this.getCurrentPrintSettings(); - return { - material: settings.material, - quality: settings.quality, - nozzleDiameter: settings.nozzleDiameter, - layerHeight: settings.layerHeight, - infillDensity: settings.infillDensity, - infillPattern: settings.infillPattern, - supportEnabled: settings.supportEnabled, - }; - } - - private shouldApplySettingsToAllItems(): boolean { - return this.parseBooleanControlValue(this.form.get('syncAllItems')?.value); - } - - private applyCurrentSettingsToAllItems(currentSettings: ItemPrintSettings): void { - this.items.update((current) => - current.map((item) => { - const variants = this.getVariantsForMaterialCode(currentSettings.material); - const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; - const byId = - item.filamentVariantId != null - ? variants.find((v) => v.id === item.filamentVariantId) - : null; - const byColor = variants.find((v) => v.colorName === item.color); - const selectedVariant = byId || byColor || fallback; - - return { - ...item, - printSettings: { ...currentSettings }, - color: selectedVariant ? selectedVariant.colorName : item.color, - filamentVariantId: selectedVariant ? selectedVariant.id : undefined, - }; - }), - ); - } - - private parseBooleanControlValue(raw: unknown): boolean { - if (this.items().length <= 1) { - return false; - } - if (raw === true || raw === 1) { - return true; - } - if (typeof raw === 'string') { - const normalized = raw.trim().toLowerCase(); - return normalized === 'true' || normalized === '1' || normalized === 'on'; - } - return false; - } - private applySettingsLock(locked: boolean): void { const controlsToLock = [ 'syncAllItems', diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 152b7f2..11592a7 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -8,8 +8,15 @@ export interface QuoteRequest { items: { file: File; quantity: number; + material?: string; + quality?: string; color?: string; filamentVariantId?: number; + supportEnabled?: boolean; + infillDensity?: number; + infillPattern?: string; + layerHeight?: number; + nozzleDiameter?: number; material?: string; quality?: string; nozzleDiameter?: number; @@ -37,8 +44,14 @@ export interface QuoteItem { unitWeight: number; // grams quantity: number; material?: string; + quality?: string; color?: string; filamentVariantId?: number; + supportEnabled?: boolean; + infillDensity?: number; + infillPattern?: string; + layerHeight?: number; + nozzleDiameter?: number; } export interface QuoteResult { @@ -109,18 +122,12 @@ export interface NumericOption { label: string; } -export interface NozzleLayerHeightsOption { - nozzleDiameter: number; - layerHeights: NumericOption[]; -} - export interface OptionsResponse { materials: MaterialOption[]; qualities: QualityOption[]; infillPatterns: InfillOption[]; layerHeights: NumericOption[]; nozzleDiameters: NumericOption[]; - layerHeightsByNozzle?: NozzleLayerHeightsOption[]; } // UI Option for Select Component @@ -157,7 +164,7 @@ export class QuoteEstimatorService { if (normalized === 'draft') { return { - quality: 'draft', + quality: 'extra_fine', layerHeight: 0.24, infillDensity: 12, infillPattern: 'grid', @@ -303,9 +310,10 @@ export class QuoteEstimatorService { const formData = new FormData(); formData.append('file', item.file); + const effectiveQuality = item.quality || request.quality; const easyPreset = request.mode === 'easy' - ? this.buildEasyModePreset(request.quality) + ? this.buildEasyModePreset(effectiveQuality) : null; const settings = { @@ -315,6 +323,10 @@ export class QuoteEstimatorService { : request.mode.toUpperCase(), material: item.material || request.material, filamentVariantId: item.filamentVariantId, + quantity: item.quantity, + quality: easyPreset ? easyPreset.quality : effectiveQuality, + supportsEnabled: + item.supportEnabled ?? request.supportEnabled ?? false, quality: easyPreset ? easyPreset.quality : item.quality || request.quality, @@ -325,15 +337,19 @@ export class QuoteEstimatorService { color: item.color || '#FFFFFF', layerHeight: easyPreset ? easyPreset.layerHeight + : (item.layerHeight ?? request.layerHeight), : item.layerHeight ?? request.layerHeight, infillDensity: easyPreset ? easyPreset.infillDensity + : (item.infillDensity ?? request.infillDensity), : item.infillDensity ?? request.infillDensity, infillPattern: easyPreset ? easyPreset.infillPattern + : (item.infillPattern ?? request.infillPattern), : item.infillPattern ?? request.infillPattern, nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter + : (item.nozzleDiameter ?? request.nozzleDiameter), : item.nozzleDiameter ?? request.nozzleDiameter, }; @@ -492,6 +508,11 @@ export class QuoteEstimatorService { material: item.materialCode || session.materialCode, color: item.colorCode, filamentVariantId: item.filamentVariantId, + supportEnabled: item.supportsEnabled, + infillDensity: item.infillPercent, + infillPattern: item.infillPattern, + layerHeight: item.layerHeightMm, + nozzleDiameter: item.nozzleDiameterMm, })), setupCost: session.setupCostChf || 0, globalMachineCost: sessionData.globalMachineCostChf || 0, From b2edf5ec4cd6d6b4a4caca497a241562d8ef4ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 17:05:15 +0100 Subject: [PATCH 05/10] fix(tutto rotto): dai che si fixa --- .../controller/QuoteSessionController.java | 720 ++++-------------- .../printcalculator/dto/PrintSettingsDto.java | 123 ++- .../printcalculator/entity/QuoteLineItem.java | 72 +- .../quote/QuoteSessionItemService.java | 251 ++++++ .../quote/QuoteSessionResponseAssembler.java | 77 ++ .../quote/QuoteSessionSettingsService.java | 179 +++++ .../service/quote/QuoteStorageService.java | 91 +++ 7 files changed, 850 insertions(+), 663 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java create mode 100644 backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java create mode 100644 backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java create mode 100644 backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 015cb8e..5f13e46 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -1,421 +1,217 @@ package com.printcalculator.controller; -import com.printcalculator.entity.FilamentMaterialType; -import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.dto.PrintSettingsDto; 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.FilamentMaterialTypeRepository; -import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.service.OrcaProfileResolver; -import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteSessionTotalsService; -import com.printcalculator.service.SlicerService; -import com.printcalculator.service.storage.ClamAVService; +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.ResponseEntity; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; 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.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.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; 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; @RestController @RequestMapping("/api/quote-sessions") - public class QuoteSessionController { - private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); - private final QuoteSessionRepository sessionRepo; private final QuoteLineItemRepository lineItemRepo; - private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; - private final PrinterMachineRepository machineRepo; - private final FilamentMaterialTypeRepository materialRepo; - private final FilamentVariantRepository variantRepo; - private final OrcaProfileResolver orcaProfileResolver; - private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; - private final ClamAVService clamAVService; private final QuoteSessionTotalsService quoteSessionTotalsService; + private final QuoteSessionItemService quoteSessionItemService; + private final QuoteStorageService quoteStorageService; + private final QuoteSessionResponseAssembler quoteSessionResponseAssembler; public QuoteSessionController(QuoteSessionRepository sessionRepo, QuoteLineItemRepository lineItemRepo, - SlicerService slicerService, QuoteCalculator quoteCalculator, - PrinterMachineRepository machineRepo, - FilamentMaterialTypeRepository materialRepo, - FilamentVariantRepository variantRepo, - OrcaProfileResolver orcaProfileResolver, - NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService, com.printcalculator.repository.PricingPolicyRepository pricingRepo, - ClamAVService clamAVService, - QuoteSessionTotalsService quoteSessionTotalsService) { + QuoteSessionTotalsService quoteSessionTotalsService, + QuoteSessionItemService quoteSessionItemService, + QuoteStorageService quoteStorageService, + QuoteSessionResponseAssembler quoteSessionResponseAssembler) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; - this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; - this.machineRepo = machineRepo; - this.materialRepo = materialRepo; - this.variantRepo = variantRepo; - this.orcaProfileResolver = orcaProfileResolver; - this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; this.pricingRepo = pricingRepo; - this.clamAVService = clamAVService; this.quoteSessionTotalsService = quoteSessionTotalsService; + this.quoteSessionItemService = quoteSessionItemService; + this.quoteStorageService = quoteStorageService; + this.quoteSessionResponseAssembler = quoteSessionResponseAssembler; } - // 1. Start a new empty session @PostMapping(value = "") @Transactional public ResponseEntity createSession() { QuoteSession session = new QuoteSession(); session.setStatus("ACTIVE"); 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.setCreatedAt(OffsetDateTime.now()); - session.setExpiresAt(OffsetDateTime.now().plusDays(30)); - + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy)); - + session = sessionRepo.save(session); return ResponseEntity.ok(session); } - - // 2. Add item to existing session + @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional - public ResponseEntity addItemToExistingSession( - @PathVariable UUID id, - @RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, - @RequestPart("file") MultipartFile file - ) throws IOException { + public ResponseEntity addItemToExistingSession(@PathVariable UUID id, + @RequestPart("settings") PrintSettingsDto settings, + @RequestPart("file") MultipartFile file) throws IOException { QuoteSession session = sessionRepo.findById(id) .orElseThrow(() -> new RuntimeException("Session not found")); - QuoteLineItem item = addItemToSession(session, file, settings); + QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings); return ResponseEntity.ok(item); } - // Helper to add item - private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { - if (file.isEmpty()) throw new IllegalArgumentException("File is empty"); + @PatchMapping("/line-items/{lineItemId}") + @Transactional + public ResponseEntity updateLineItem(@PathVariable UUID lineItemId, + @RequestBody Map 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"); } - // Scan for virus - clamAVService.scan(file.getInputStream()); - - // 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"); + if (updates.containsKey("quantity")) { + item.setQuantity(parsePositiveQuantity(updates.get("quantity"))); } - Files.createDirectories(sessionStorageDir); - - String originalFilename = file.getOriginalFilename(); - String ext = getSafeExtension(originalFilename, "stl"); - String storedFilename = UUID.randomUUID() + "." + ext; - Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize(); - if (!persistentPath.startsWith(sessionStorageDir)) { - throw new IOException("Invalid quote line-item storage path"); + if (updates.containsKey("color_code")) { + Object colorValue = updates.get("color_code"); + if (colorValue != null) { + item.setColorCode(String.valueOf(colorValue)); + } } - // 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 = 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( - 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()); - - // 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(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 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 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 = 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 = 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.setMaterialCode(selectedVariant.getFilamentMaterialType() != null - ? selectedVariant.getFilamentMaterialType().getMaterialCode() - : normalizeRequestedMaterialCode(settings.getMaterial())); - item.setQuality(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"); // or CALCULATED - - item.setPrintTimeSeconds((int) stats.printTimeSeconds()); - item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); - item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); - - // Store breakdown - Map 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()); - - return lineItemRepo.save(item); - - } catch (Exception e) { - // Cleanup if failed - Files.deleteIfExists(persistentPath); - if (convertedPersistentPath != null) { - Files.deleteIfExists(convertedPersistentPath); - } - throw e; - } + item.setUpdatedAt(OffsetDateTime.now()); + return ResponseEntity.ok(lineItemRepo.save(item)); } - private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { - if (settings.getNozzleDiameter() == null) { - settings.setNozzleDiameter(0.40); + @DeleteMapping("/{sessionId}/line-items/{lineItemId}") + @Transactional + public ResponseEntity 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(); } - if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { - // Set defaults based on Quality - String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; - - switch (quality) { - case "draft": - settings.setLayerHeight(0.28); - settings.setInfillDensity(15.0); - settings.setInfillPattern("grid"); - break; - case "extra_fine": - case "high_definition": - 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.getInfillDensity() == null) settings.setInfillDensity(20.0); - if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); - } + lineItemRepo.delete(item); + return ResponseEntity.noContent().build(); } - private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.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())); + @GetMapping("/{id}") + public ResponseEntity> getQuoteSession(@PathVariable UUID id) { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + List items = lineItemRepo.findByQuoteSessionId(id); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals)); } - private 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; + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") + public ResponseEntity 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(); } - return machineRepo.findFirstByIsActiveTrue() - .orElseThrow(() -> new RuntimeException("No active printer found")); - } - - private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.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 byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor); - if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) { - return byColor.get(); + String targetStoredPath = item.getStoredPath(); + if (preview) { + String convertedPath = quoteStorageService.extractConvertedStoredPath(item); + if (convertedPath != null && !convertedPath.isBlank()) { + targetStoredPath = convertedPath; } } - return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) - .orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode)); - } - - private String normalizeRequestedMaterialCode(String value) { - if (value == null || value.isBlank()) { - return "PLA"; + if (targetStoredPath == null) { + return ResponseEntity.notFound().build(); } - return value.trim() - .toUpperCase(Locale.ROOT) - .replace('_', ' ') - .replace('-', ' ') - .replaceAll("\\s+", " "); + java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId); + if (path == null || !java.nio.file.Files.exists(path)) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(path.toUri()); + String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename(); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"") + .body(resource); + } + + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview") + public ResponseEntity 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(); + } + + if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) { + return ResponseEntity.notFound().build(); + } + + String targetStoredPath = item.getStoredPath(); + if (targetStoredPath == null || targetStoredPath.isBlank()) { + return ResponseEntity.notFound().build(); + } + + java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId); + if (path == null || !java.nio.file.Files.exists(path)) { + return ResponseEntity.notFound().build(); + } + + if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(path.toUri()); + String downloadName = path.getFileName().toString(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("model/stl")) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"") + .body(resource); } private int parsePositiveQuantity(Object raw) { @@ -443,262 +239,4 @@ public class QuoteSessionController { } return quantity; } - - // 3. Update Line Item - @PatchMapping("/line-items/{lineItemId}") - @Transactional - public ResponseEntity updateLineItem( - @PathVariable UUID lineItemId, - @RequestBody Map 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 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> getQuoteSession(@PathVariable UUID id) { - QuoteSession session = sessionRepo.findById(id) - .orElseThrow(() -> new RuntimeException("Session not found")); - - List items = lineItemRepo.findByQuoteSessionId(id); - QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); - - // Map items to DTO to embed distributed machine cost - List> itemsDto = new ArrayList<>(); - for (QuoteLineItem item : items) { - Map 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", 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 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 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); - } - - // 7. Download STL preview for checkout (only when original file is STL) - @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview") - public ResponseEntity 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(); - } - - // Only expose preview for native STL uploads. - if (!"stl".equals(getSafeExtension(item.getOriginalFilename(), ""))) { - return ResponseEntity.notFound().build(); - } - - String targetStoredPath = item.getStoredPath(); - if (targetStoredPath == null || targetStoredPath.isBlank()) { - return ResponseEntity.notFound().build(); - } - - Path path = resolveStoredQuotePath(targetStoredPath, sessionId); - if (path == null || !Files.exists(path)) { - return ResponseEntity.notFound().build(); - } - - if (!"stl".equals(getSafeExtension(path.getFileName().toString(), ""))) { - return ResponseEntity.notFound().build(); - } - - Resource resource = new UrlResource(path.toUri()); - String downloadName = path.getFileName().toString(); - - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("model/stl")) - .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; 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 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; - } - - private String resolveQuality(com.printcalculator.dto.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"; - } } diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java index e01cfc6..1d2a4df 100644 --- a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -1,8 +1,5 @@ package com.printcalculator.dto; -import lombok.Data; - -@Data public class PrintSettingsDto { // Mode: "BASIC" or "ADVANCED" private String complexityMode; @@ -28,4 +25,124 @@ public class PrintSettingsDto { private Double boundingBoxX; private Double boundingBoxY; 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; + } } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index 9849cab..4103f8c 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -51,36 +51,18 @@ public class QuoteLineItem { @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; - - @Column(name = "material_code", length = Integer.MAX_VALUE) - private String materialCode; - @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_pattern", length = Integer.MAX_VALUE) - private String infillPattern; - @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; @@ -232,54 +214,6 @@ public class QuoteLineItem { this.supportsEnabled = supportsEnabled; } - public String getMaterialCode() { - return materialCode; - } - - public void setMaterialCode(String materialCode) { - this.materialCode = materialCode; - } - - 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 String getInfillPattern() { - return infillPattern; - } - - public void setInfillPattern(String infillPattern) { - this.infillPattern = infillPattern; - } - - public Integer getInfillPercent() { - return infillPercent; - } - - public void setInfillPercent(Integer infillPercent) { - this.infillPercent = infillPercent; - } - - public Boolean getSupportsEnabled() { - return supportsEnabled; - } - - public void setSupportsEnabled(Boolean supportsEnabled) { - this.supportsEnabled = supportsEnabled; - } - public BigDecimal getBoundingBoxXMm() { return boundingBoxXMm; } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java new file mode 100644 index 0000000..9797c7f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -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 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 = 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, + 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 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java new file mode 100644 index 0000000..f5e8721 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -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 assemble(QuoteSession session, + List items, + QuoteSessionTotalsService.QuoteSessionTotals totals) { + List> itemsDto = new ArrayList<>(); + for (QuoteLineItem item : items) { + itemsDto.add(toItemDto(item, totals)); + } + + Map 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 toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) { + Map 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java new file mode 100644 index 0000000..c07cf08 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionSettingsService.java @@ -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 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) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java new file mode 100644 index 0000000..87e5e44 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java @@ -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 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; + } +} From 8e23bd97e68f2f79ea67dc95bf8dd9efd083a110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 17:07:25 +0100 Subject: [PATCH 06/10] fix(tutto rotto): dai che si fixa --- .../admin/AdminOrderController.java | 6 +- .../services/quote-estimator.service.ts | 482 ++++++++---------- 2 files changed, 224 insertions(+), 264 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index cbd0855..9508b5f 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -4,14 +4,12 @@ import com.printcalculator.dto.AddressDto; import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderItemDto; -import com.printcalculator.entity.Order; -import com.printcalculator.entity.OrderItem; -import com.printcalculator.entity.Payment; -import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.*; import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.QrBillService; diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 11592a7..93ded57 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -1,30 +1,24 @@ import { Injectable, inject, signal } from '@angular/core'; import { HttpClient, HttpEventType } from '@angular/common/http'; import { Observable, of } from 'rxjs'; -import { map, catchError, tap } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; +export interface QuoteRequestItem { + file: File; + quantity: number; + material?: string; + quality?: string; + color?: string; + filamentVariantId?: number; + supportEnabled?: boolean; + infillDensity?: number; + infillPattern?: string; + layerHeight?: number; + nozzleDiameter?: number; +} + export interface QuoteRequest { - items: { - file: File; - quantity: number; - material?: string; - quality?: string; - color?: string; - filamentVariantId?: number; - supportEnabled?: boolean; - infillDensity?: number; - infillPattern?: string; - layerHeight?: number; - nozzleDiameter?: number; - material?: string; - quality?: string; - nozzleDiameter?: number; - layerHeight?: number; - infillDensity?: number; - infillPattern?: string; - supportEnabled?: boolean; - }[]; + items: QuoteRequestItem[]; material: string; quality: string; notes?: string; @@ -40,8 +34,8 @@ export interface QuoteItem { id?: string; fileName: string; unitPrice: number; - unitTime: number; // seconds - unitWeight: number; // grams + unitTime: number; + unitWeight: number; quantity: number; material?: string; quality?: string; @@ -69,36 +63,12 @@ export interface QuoteResult { 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 { code: string; label: string; variants: VariantOption[]; } + export interface VariantOption { id: number; name: string; @@ -109,28 +79,36 @@ export interface VariantOption { stockFilamentGrams: number; isOutOfStock: boolean; } + export interface QualityOption { id: string; label: string; } + export interface InfillOption { id: string; label: string; } + export interface NumericOption { value: number; label: string; } +export interface NozzleLayerHeightOptions { + nozzleDiameter: number; + layerHeights: NumericOption[]; +} + export interface OptionsResponse { materials: MaterialOption[]; qualities: QualityOption[]; infillPatterns: InfillOption[]; layerHeights: NumericOption[]; nozzleDiameters: NumericOption[]; + layerHeightsByNozzle: NozzleLayerHeightOptions[]; } -// UI Option for Select Component export interface SimpleOption { value: string | number; label: string; @@ -142,70 +120,23 @@ export interface SimpleOption { export class QuoteEstimatorService { private http = inject(HttpClient); - private buildEasyModePreset(quality: string | undefined): { - quality: string; - layerHeight: number; - infillDensity: number; - 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, - }; - } + private pendingConsultation = signal<{ + files: File[]; + message: string; + } | null>(null); getOptions(): Observable { - console.log('QuoteEstimatorService: Requesting options...'); const headers: any = {}; - return this.http - .get(`${environment.apiUrl}/api/calculator/options`, { - headers, - }) - .pipe( - tap({ - next: (res) => - console.log('QuoteEstimatorService: Options loaded', res), - error: (err) => - console.error('QuoteEstimatorService: Options failed', err), - }), - ); + return this.http.get(`${environment.apiUrl}/api/calculator/options`, { + headers, + }); } - // NEW METHODS for Order Flow - getQuoteSession(sessionId: string): Observable { const headers: any = {}; - return this.http.get( - `${environment.apiUrl}/api/quote-sessions/${sessionId}`, - { headers }, - ); + return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { + headers, + }); } updateLineItem(lineItemId: string, changes: any): Observable { @@ -244,13 +175,10 @@ export class QuoteEstimatorService { getOrderInvoice(orderId: string): Observable { const headers: any = {}; - return this.http.get( - `${environment.apiUrl}/api/orders/${orderId}/invoice`, - { - headers, - responseType: 'blob', - }, - ); + return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, { + headers, + responseType: 'blob', + }); } getOrderConfirmation(orderId: string): Observable { @@ -272,87 +200,68 @@ export class QuoteEstimatorService { } calculate(request: QuoteRequest): Observable { - console.log('QuoteEstimatorService: Calculating quote...', request); - if (request.items.length === 0) { - console.warn('QuoteEstimatorService: No items to calculate'); - return of(); + if (!request.items || request.items.length === 0) { + return of(0); } - return new Observable((observer) => { - // 1. Create Session first + return new Observable((observer) => { const headers: any = {}; this.http .post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }) .subscribe({ next: (sessionRes) => { - const sessionId = sessionRes.id; - const sessionSetupCost = sessionRes.setupCostChf || 0; + const sessionId = String(sessionRes?.id || ''); + if (!sessionId) { + observer.error('Could not initialize quote session'); + return; + } - // 2. Upload files to this session const totalItems = request.items.length; - const allProgress: number[] = new Array(totalItems).fill(0); - const finalResponses: any[] = []; - let completedRequests = 0; + const uploadProgress = new Array(totalItems).fill(0); + const uploadResults: { success: boolean }[] = new Array(totalItems) + .fill(null) + .map(() => ({ success: false })); + let completed = 0; - const checkCompletion = () => { + const emitProgress = () => { const avg = Math.round( - allProgress.reduce((a, b) => a + b, 0) / totalItems, + uploadProgress.reduce((sum, value) => sum + value, 0) / totalItems, ); observer.next(avg); + }; - if (completedRequests === totalItems) { - finalize(finalResponses, sessionSetupCost, sessionId); + const finalize = () => { + 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) => { const formData = new FormData(); formData.append('file', item.file); - const effectiveQuality = item.quality || request.quality; - const easyPreset = - request.mode === 'easy' - ? this.buildEasyModePreset(effectiveQuality) - : null; - - const settings = { - complexityMode: - request.mode === 'easy' - ? 'ADVANCED' - : request.mode.toUpperCase(), - material: item.material || request.material, - filamentVariantId: item.filamentVariantId, - quantity: item.quantity, - quality: easyPreset ? easyPreset.quality : effectiveQuality, - supportsEnabled: - item.supportEnabled ?? request.supportEnabled ?? false, - quality: easyPreset - ? easyPreset.quality - : item.quality || request.quality, - supportsEnabled: - easyPreset != null - ? request.supportEnabled - : item.supportEnabled ?? request.supportEnabled, - color: item.color || '#FFFFFF', - layerHeight: easyPreset - ? easyPreset.layerHeight - : (item.layerHeight ?? request.layerHeight), - : item.layerHeight ?? request.layerHeight, - infillDensity: easyPreset - ? easyPreset.infillDensity - : (item.infillDensity ?? request.infillDensity), - : item.infillDensity ?? request.infillDensity, - infillPattern: easyPreset - ? easyPreset.infillPattern - : (item.infillPattern ?? request.infillPattern), - : item.infillPattern ?? request.infillPattern, - nozzleDiameter: easyPreset - ? easyPreset.nozzleDiameter - : (item.nozzleDiameter ?? request.nozzleDiameter), - : item.nozzleDiameter ?? request.nozzleDiameter, - }; - + const settings = this.buildSettingsPayload(request, item); const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json', }); @@ -374,84 +283,46 @@ export class QuoteEstimatorService { event.type === HttpEventType.UploadProgress && event.total ) { - allProgress[index] = Math.round( + uploadProgress[index] = Math.round( (100 * event.loaded) / event.total, ); - checkCompletion(); - } else if (event.type === HttpEventType.Response) { - allProgress[index] = 100; - finalResponses[index] = { - ...event.body, - success: true, - fileName: item.file.name, - originalQty: item.quantity, - originalItem: item, - }; - completedRequests++; - checkCompletion(); + emitProgress(); + return; + } + + if (event.type === HttpEventType.Response) { + uploadProgress[index] = 100; + uploadResults[index] = { success: true }; + completed += 1; + finalize(); } }, - error: (err) => { - console.error('Item upload failed', err); - finalResponses[index] = { - success: false, - fileName: item.file.name, - }; - completedRequests++; - checkCompletion(); + error: () => { + uploadProgress[index] = 100; + uploadResults[index] = { success: false }; + completed += 1; + finalize(); }, }); }); }, - error: (err) => { - console.error('Failed to create session', err); + error: () => { observer.error('Could not initialize quote session'); }, }); - - const finalize = ( - responses: any[], - setupCost: number, - sessionId: string, - ) => { - this.http - .get(`${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 }) { this.pendingConsultation.set(data); } getPendingConsultation() { const data = this.pendingConsultation(); - this.pendingConsultation.set(null); // Clear after reading + this.pendingConsultation.set(null); return data; } - // Session File Retrieval getLineItemContent( sessionId: string, lineItemId: string, @@ -483,50 +354,141 @@ export class QuoteEstimatorService { } mapSessionToQuoteResult(sessionData: any): QuoteResult { - const session = sessionData.session; - const items = sessionData.items || []; + const session = sessionData?.session || {}; + const items = Array.isArray(sessionData?.items) ? sessionData.items : []; + const totalTime = items.reduce( (acc: number, item: any) => - acc + (item.printTimeSeconds || 0) * item.quantity, - 0, - ); - const totalWeight = items.reduce( - (acc: number, item: any) => - acc + (item.materialGrams || 0) * item.quantity, + acc + Number(item?.printTimeSeconds || 0) * Number(item?.quantity || 1), 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 { - sessionId: session.id, + sessionId: session?.id, items: items.map((item: any) => ({ - id: item.id, - fileName: item.originalFilename, - unitPrice: item.unitPriceChf, - unitTime: item.printTimeSeconds, - unitWeight: item.materialGrams, - quantity: item.quantity, - material: item.materialCode || session.materialCode, - color: item.colorCode, - filamentVariantId: item.filamentVariantId, - supportEnabled: item.supportsEnabled, - infillDensity: item.infillPercent, - infillPattern: item.infillPattern, - layerHeight: item.layerHeightMm, - nozzleDiameter: item.nozzleDiameterMm, + id: item?.id, + fileName: item?.originalFilename, + unitPrice: Number(item?.unitPriceChf || 0), + unitTime: Number(item?.printTimeSeconds || 0), + unitWeight: Number(item?.materialGrams || 0), + quantity: Number(item?.quantity || 1), + material: item?.materialCode || session?.materialCode, + quality: item?.quality, + color: item?.colorCode, + 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, - globalMachineCost: sessionData.globalMachineCostChf || 0, - cadHours: session.cadHours || 0, - cadTotal: sessionData.cadTotalChf || 0, - currency: 'CHF', // Fixed for now - totalPrice: - (sessionData.itemsTotalChf || 0) + - (session.setupCostChf || 0) + - (sessionData.shippingCostChf || 0), + setupCost: Number(session?.setupCostChf || 0), + globalMachineCost: Number(sessionData?.globalMachineCostChf || 0), + cadHours: Number(session?.cadHours || 0), + cadTotal: Number(sessionData?.cadTotalChf || 0), + currency: 'CHF', + totalPrice: Number.isFinite(grandTotal) ? grandTotal : fallbackTotal, totalTimeHours: Math.floor(totalTime / 3600), totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), 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, }; } } From a7491130fba0db736cdef0df8a006bd207de61e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 17:28:07 +0100 Subject: [PATCH 07/10] chore(back-end and front-end): refractor and improvements calculator --- .../controller/OrderController.java | 352 +--- .../admin/AdminOrderController.java | 389 +--- .../order/AdminOrderControllerService.java | 423 +++++ .../service/order/OrderControllerService.java | 352 ++++ .../OrderControllerPrivacyTest.java | 16 +- ...inOrderControllerStatusValidationTest.java | 8 +- .../quote-result/quote-result.component.html | 8 +- .../quote-result/quote-result.component.scss | 19 +- .../quote-result/quote-result.component.ts | 22 +- .../upload-form/upload-form.component.html | 275 +-- .../upload-form/upload-form.component.scss | 113 +- .../upload-form/upload-form.component.ts | 1687 ++++++++++------- 12 files changed, 1989 insertions(+), 1675 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index f83cf59..8c1373b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -1,146 +1,62 @@ package com.printcalculator.controller; -import com.printcalculator.dto.*; -import com.printcalculator.entity.*; -import com.printcalculator.repository.*; -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 com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.service.order.OrderControllerService; +import jakarta.validation.Valid; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import jakarta.validation.Valid; import java.io.IOException; - -import java.nio.file.InvalidPathException; -import java.nio.file.Path; - -import java.util.List; -import java.util.UUID; import java.util.Map; -import java.util.HashMap; -import java.util.Base64; -import java.util.Set; -import java.util.stream.Collectors; -import java.net.URI; -import java.util.Locale; -import java.util.regex.Pattern; +import java.util.UUID; @RestController @RequestMapping("/api/orders") public class OrderController { - private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); - private static final Set PERSONAL_DATA_REDACTED_STATUSES = Set.of( - "IN_PRODUCTION", - "SHIPPED", - "COMPLETED" - ); - private final OrderService orderService; - private final OrderRepository orderRepo; - private final OrderItemRepository orderItemRepo; - private final QuoteSessionRepository quoteSessionRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final CustomerRepository customerRepo; - private final StorageService storageService; - private final InvoicePdfRenderingService invoiceService; - private final QrBillService qrBillService; - private final TwintPaymentService twintPaymentService; - private final PaymentService paymentService; - private final PaymentRepository paymentRepo; + private final OrderControllerService orderControllerService; - - public OrderController(OrderService orderService, - OrderRepository orderRepo, - OrderItemRepository orderItemRepo, - QuoteSessionRepository quoteSessionRepo, - QuoteLineItemRepository quoteLineItemRepo, - CustomerRepository customerRepo, - StorageService storageService, - InvoicePdfRenderingService invoiceService, - QrBillService qrBillService, - TwintPaymentService twintPaymentService, - PaymentService paymentService, - PaymentRepository paymentRepo) { - this.orderService = orderService; - this.orderRepo = orderRepo; - this.orderItemRepo = orderItemRepo; - this.quoteSessionRepo = quoteSessionRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.customerRepo = customerRepo; - this.storageService = storageService; - this.invoiceService = invoiceService; - this.qrBillService = qrBillService; - this.twintPaymentService = twintPaymentService; - this.paymentService = paymentService; - this.paymentRepo = paymentRepo; + public OrderController(OrderControllerService orderControllerService) { + this.orderControllerService = orderControllerService; } - - // 1. Create Order from Quote @PostMapping("/from-quote/{quoteSessionId}") @Transactional public ResponseEntity createOrderFromQuote( @PathVariable UUID quoteSessionId, - @Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request + @Valid @RequestBody CreateOrderRequest request ) { - Order order = orderService.createOrderFromQuote(quoteSessionId, request); - List items = orderItemRepo.findByOrder_Id(order.getId()); - return ResponseEntity.ok(convertToDto(order, items)); + return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request)); } - + @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity uploadOrderItemFile( - @PathVariable UUID orderId, - @PathVariable UUID orderItemId, - @RequestParam("file") MultipartFile file + @PathVariable UUID orderId, + @PathVariable UUID orderItemId, + @RequestParam("file") MultipartFile file ) throws IOException { - - OrderItem item = orderItemRepo.findById(orderItemId) - .orElseThrow(() -> new RuntimeException("OrderItem not found")); - - if (!item.getOrder().getId().equals(orderId)) { + boolean uploaded = orderControllerService.uploadOrderItemFile(orderId, orderItemId, file); + if (!uploaded) { return ResponseEntity.badRequest().build(); } - - String relativePath = item.getStoredRelativePath(); - Path destinationRelativePath; - if (relativePath == null || relativePath.equals("PENDING")) { - String ext = getExtension(file.getOriginalFilename()); - String storedFilename = UUID.randomUUID() + "." + ext; - destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); - item.setStoredRelativePath(destinationRelativePath.toString()); - item.setStoredFilename(storedFilename); - } else { - destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); - if (destinationRelativePath == null) { - return ResponseEntity.badRequest().build(); - } - } - - storageService.store(file, destinationRelativePath); - item.setFileSizeBytes(file.getSize()); - item.setMimeType(file.getContentType()); - orderItemRepo.save(item); - return ResponseEntity.ok().build(); } @GetMapping("/{orderId}") public ResponseEntity getOrder(@PathVariable UUID orderId) { - return orderRepo.findById(orderId) - .map(o -> { - List items = orderItemRepo.findByOrder_Id(o.getId()); - return ResponseEntity.ok(convertToDto(o, items)); - }) + return orderControllerService.getOrder(orderId) + .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @@ -150,89 +66,29 @@ public class OrderController { @PathVariable UUID orderId, @RequestBody Map payload ) { - String method = payload.get("method"); - paymentService.reportPayment(orderId, method); - return getOrder(orderId); + return orderControllerService.reportPayment(orderId, payload.get("method")) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } @GetMapping("/{orderId}/confirmation") public ResponseEntity getConfirmation(@PathVariable UUID orderId) { - return generateDocument(orderId, true); + return orderControllerService.getConfirmation(orderId); } @GetMapping("/{orderId}/invoice") public ResponseEntity getInvoice(@PathVariable UUID orderId) { - // Paid invoices are sent by email after back-office payment confirmation. - // The public endpoint must not expose a "paid" invoice download. return ResponseEntity.notFound().build(); } - private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { - Order order = orderRepo.findById(orderId) - .orElseThrow(() -> new RuntimeException("Order not found")); - - if (isConfirmation) { - Path relativePath = buildConfirmationPdfRelativePath(order); - try { - byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); - return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(existingPdf); - } catch (Exception ignored) { - // Fallback to on-the-fly generation if the stored file is missing or unreadable. - } - } - - List items = orderItemRepo.findByOrder_Id(orderId); - Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); - - byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); - String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; - String truncatedUuid = order.getId().toString().substring(0, 8); - return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(pdf); - } - - private Path buildConfirmationPdfRelativePath(Order order) { - return Path.of( - "orders", - order.getId().toString(), - "documents", - "confirmation-" + getDisplayOrderNumber(order) + ".pdf" - ); - } - @GetMapping("/{orderId}/twint") public ResponseEntity> getTwintPayment(@PathVariable UUID orderId) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - byte[] qrPng = twintPaymentService.generateQrPng(order, 360); - String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); - - Map data = new HashMap<>(); - data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order)); - data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); - data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); - data.put("qrImageDataUri", qrDataUri); - return ResponseEntity.ok(data); + return orderControllerService.getTwintPayment(orderId); } @GetMapping("/{orderId}/twint/open") public ResponseEntity openTwintPayment(@PathVariable UUID orderId) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.status(302) - .location(URI.create(twintPaymentService.getTwintPaymentUrl(order))) - .build(); + return orderControllerService.openTwintPayment(orderId); } @GetMapping("/{orderId}/twint/qr") @@ -240,150 +96,6 @@ public class OrderController { @PathVariable UUID orderId, @RequestParam(defaultValue = "320") int size ) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - int normalizedSize = Math.max(200, Math.min(size, 600)); - byte[] png = twintPaymentService.generateQrPng(order, normalizedSize); - - return ResponseEntity.ok() - .contentType(MediaType.IMAGE_PNG) - .body(png); + return orderControllerService.getTwintQr(orderId, size); } - - private String getExtension(String filename) { - if (filename == null) return "stl"; - String cleaned = StringUtils.cleanPath(filename); - if (cleaned.contains("..")) { - return "stl"; - } - int i = cleaned.lastIndexOf('.'); - if (i > 0 && i < cleaned.length() - 1) { - String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); - if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { - return ext; - } - } - return "stl"; - } - - private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { - try { - Path candidate = Path.of(storedRelativePath).normalize(); - if (candidate.isAbsolute()) { - return null; - } - - Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); - if (!candidate.startsWith(expectedPrefix)) { - return null; - } - - return candidate; - } catch (InvalidPathException e) { - return null; - } - } - - private OrderDto convertToDto(Order order, List items) { - OrderDto dto = new OrderDto(); - dto.setId(order.getId()); - dto.setOrderNumber(getDisplayOrderNumber(order)); - dto.setStatus(order.getStatus()); - - paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { - dto.setPaymentStatus(p.getStatus()); - dto.setPaymentMethod(p.getMethod()); - }); - - boolean redactPersonalData = shouldRedactPersonalData(order.getStatus()); - if (!redactPersonalData) { - dto.setCustomerEmail(order.getCustomerEmail()); - dto.setCustomerPhone(order.getCustomerPhone()); - dto.setBillingCustomerType(order.getBillingCustomerType()); - } - dto.setPreferredLanguage(order.getPreferredLanguage()); - dto.setCurrency(order.getCurrency()); - dto.setSetupCostChf(order.getSetupCostChf()); - dto.setShippingCostChf(order.getShippingCostChf()); - dto.setDiscountChf(order.getDiscountChf()); - dto.setSubtotalChf(order.getSubtotalChf()); - dto.setIsCadOrder(order.getIsCadOrder()); - dto.setSourceRequestId(order.getSourceRequestId()); - dto.setCadHours(order.getCadHours()); - dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); - dto.setCadTotalChf(order.getCadTotalChf()); - dto.setTotalChf(order.getTotalChf()); - dto.setCreatedAt(order.getCreatedAt()); - dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); - - if (!redactPersonalData) { - AddressDto billing = new AddressDto(); - billing.setFirstName(order.getBillingFirstName()); - billing.setLastName(order.getBillingLastName()); - billing.setCompanyName(order.getBillingCompanyName()); - billing.setContactPerson(order.getBillingContactPerson()); - billing.setAddressLine1(order.getBillingAddressLine1()); - billing.setAddressLine2(order.getBillingAddressLine2()); - billing.setZip(order.getBillingZip()); - billing.setCity(order.getBillingCity()); - billing.setCountryCode(order.getBillingCountryCode()); - dto.setBillingAddress(billing); - - if (!order.getShippingSameAsBilling()) { - AddressDto shipping = new AddressDto(); - shipping.setFirstName(order.getShippingFirstName()); - shipping.setLastName(order.getShippingLastName()); - shipping.setCompanyName(order.getShippingCompanyName()); - shipping.setContactPerson(order.getShippingContactPerson()); - shipping.setAddressLine1(order.getShippingAddressLine1()); - shipping.setAddressLine2(order.getShippingAddressLine2()); - shipping.setZip(order.getShippingZip()); - shipping.setCity(order.getShippingCity()); - shipping.setCountryCode(order.getShippingCountryCode()); - dto.setShippingAddress(shipping); - } - } - - List itemDtos = items.stream().map(i -> { - OrderItemDto idto = new OrderItemDto(); - idto.setId(i.getId()); - idto.setOriginalFilename(i.getOriginalFilename()); - idto.setMaterialCode(i.getMaterialCode()); - 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.setPrintTimeSeconds(i.getPrintTimeSeconds()); - idto.setMaterialGrams(i.getMaterialGrams()); - idto.setUnitPriceChf(i.getUnitPriceChf()); - idto.setLineTotalChf(i.getLineTotalChf()); - return idto; - }).collect(Collectors.toList()); - dto.setItems(itemDtos); - - 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) { - String orderNumber = order.getOrderNumber(); - if (orderNumber != null && !orderNumber.isBlank()) { - return orderNumber; - } - return order.getId() != null ? order.getId().toString() : "unknown"; - } - } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 9508b5f..797310e 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -1,25 +1,9 @@ package com.printcalculator.controller.admin; -import com.printcalculator.dto.AddressDto; import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.OrderDto; -import com.printcalculator.dto.OrderItemDto; -import com.printcalculator.entity.*; -import com.printcalculator.event.OrderShippedEvent; -import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.repository.OrderRepository; -import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.repository.QuoteLineItemRepository; -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 com.printcalculator.service.order.AdminOrderControllerService; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; @@ -28,80 +12,30 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Comparator; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.UUID; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.NOT_FOUND; - @RestController @RequestMapping("/api/admin/orders") @Transactional(readOnly = true) public class AdminOrderController { - private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); - private static final List ALLOWED_ORDER_STATUSES = List.of( - "PENDING_PAYMENT", - "PAID", - "IN_PRODUCTION", - "SHIPPED", - "COMPLETED", - "CANCELLED" - ); - private final OrderRepository orderRepo; - private final OrderItemRepository orderItemRepo; - private final PaymentRepository paymentRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final PaymentService paymentService; - private final StorageService storageService; - private final InvoicePdfRenderingService invoiceService; - private final QrBillService qrBillService; - private final ApplicationEventPublisher eventPublisher; + private final AdminOrderControllerService adminOrderControllerService; - public AdminOrderController( - OrderRepository orderRepo, - OrderItemRepository orderItemRepo, - PaymentRepository paymentRepo, - QuoteLineItemRepository quoteLineItemRepo, - PaymentService paymentService, - StorageService storageService, - InvoicePdfRenderingService invoiceService, - QrBillService qrBillService, - ApplicationEventPublisher eventPublisher - ) { - this.orderRepo = orderRepo; - this.orderItemRepo = orderItemRepo; - this.paymentRepo = paymentRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.paymentService = paymentService; - this.storageService = storageService; - this.invoiceService = invoiceService; - this.qrBillService = qrBillService; - this.eventPublisher = eventPublisher; + public AdminOrderController(AdminOrderControllerService adminOrderControllerService) { + this.adminOrderControllerService = adminOrderControllerService; } @GetMapping public ResponseEntity> listOrders() { - List response = orderRepo.findAllByOrderByCreatedAtDesc() - .stream() - .map(this::toOrderDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOrderControllerService.listOrders()); } @GetMapping("/{orderId}") public ResponseEntity getOrder(@PathVariable UUID orderId) { - return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId)); } @PostMapping("/{orderId}/payments/confirm") @@ -110,13 +44,7 @@ public class AdminOrderController { @PathVariable UUID orderId, @RequestBody(required = false) Map payload ) { - getOrderOrThrow(orderId); - String method = payload != null ? payload.get("method") : null; - 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(adminOrderControllerService.updatePaymentMethod(orderId, payload)); } @PostMapping("/{orderId}/status") @@ -125,28 +53,7 @@ public class AdminOrderController { @PathVariable UUID orderId, @RequestBody AdminOrderStatusUpdateRequest payload ) { - if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { - throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required"); - } - - Order order = getOrderOrThrow(orderId); - String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT); - if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) { - throw new ResponseStatusException( - BAD_REQUEST, - "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) - ); - } - String previousStatus = order.getStatus(); - order.setStatus(normalizedStatus); - Order savedOrder = orderRepo.save(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)); + return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload)); } @GetMapping("/{orderId}/items/{orderItemId}/file") @@ -154,290 +61,16 @@ public class AdminOrderController { @PathVariable UUID orderId, @PathVariable UUID orderItemId ) { - OrderItem item = orderItemRepo.findById(orderItemId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found")); - - if (!item.getOrder().getId().equals(orderId)) { - throw new ResponseStatusException(NOT_FOUND, "Order item not found for order"); - } - - String relativePath = item.getStoredRelativePath(); - if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } - Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); - if (safeRelativePath == null) { - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } - - try { - Resource resource = storageService.loadAsResource(safeRelativePath); - MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; - if (item.getMimeType() != null && !item.getMimeType().isBlank()) { - try { - contentType = MediaType.parseMediaType(item.getMimeType()); - } catch (Exception ignored) { - contentType = MediaType.APPLICATION_OCTET_STREAM; - } - } - - String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank() - ? item.getOriginalFilename() - : "order-item-" + orderItemId; - - return ResponseEntity.ok() - .contentType(contentType) - .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build() - .toString()) - .body(resource); - } catch (Exception e) { - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } + return adminOrderControllerService.downloadOrderItemFile(orderId, orderItemId); } @GetMapping("/{orderId}/documents/confirmation") public ResponseEntity downloadOrderConfirmation(@PathVariable UUID orderId) { - return generateDocument(getOrderOrThrow(orderId), true); + return adminOrderControllerService.downloadOrderConfirmation(orderId); } @GetMapping("/{orderId}/documents/invoice") public ResponseEntity downloadOrderInvoice(@PathVariable UUID orderId) { - return generateDocument(getOrderOrThrow(orderId), false); - } - - private Order getOrderOrThrow(UUID orderId) { - return orderRepo.findById(orderId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); - } - - private OrderDto toOrderDto(Order order) { - List items = orderItemRepo.findByOrder_Id(order.getId()); - OrderDto dto = new OrderDto(); - dto.setId(order.getId()); - dto.setOrderNumber(getDisplayOrderNumber(order)); - dto.setStatus(order.getStatus()); - - paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { - dto.setPaymentStatus(p.getStatus()); - dto.setPaymentMethod(p.getMethod()); - }); - - dto.setCustomerEmail(order.getCustomerEmail()); - dto.setCustomerPhone(order.getCustomerPhone()); - dto.setPreferredLanguage(order.getPreferredLanguage()); - dto.setBillingCustomerType(order.getBillingCustomerType()); - dto.setCurrency(order.getCurrency()); - dto.setSetupCostChf(order.getSetupCostChf()); - dto.setShippingCostChf(order.getShippingCostChf()); - dto.setDiscountChf(order.getDiscountChf()); - dto.setSubtotalChf(order.getSubtotalChf()); - dto.setIsCadOrder(order.getIsCadOrder()); - dto.setSourceRequestId(order.getSourceRequestId()); - dto.setCadHours(order.getCadHours()); - dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); - dto.setCadTotalChf(order.getCadTotalChf()); - dto.setTotalChf(order.getTotalChf()); - dto.setCreatedAt(order.getCreatedAt()); - dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); - QuoteSession sourceSession = order.getSourceQuoteSession(); - if (sourceSession != null) { - dto.setPrintMaterialCode(sourceSession.getMaterialCode()); - dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm()); - dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm()); - dto.setPrintInfillPattern(sourceSession.getInfillPattern()); - dto.setPrintInfillPercent(sourceSession.getInfillPercent()); - dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled()); - } - - AddressDto billing = new AddressDto(); - billing.setFirstName(order.getBillingFirstName()); - billing.setLastName(order.getBillingLastName()); - billing.setCompanyName(order.getBillingCompanyName()); - billing.setContactPerson(order.getBillingContactPerson()); - billing.setAddressLine1(order.getBillingAddressLine1()); - billing.setAddressLine2(order.getBillingAddressLine2()); - billing.setZip(order.getBillingZip()); - billing.setCity(order.getBillingCity()); - billing.setCountryCode(order.getBillingCountryCode()); - dto.setBillingAddress(billing); - - if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { - AddressDto shipping = new AddressDto(); - shipping.setFirstName(order.getShippingFirstName()); - shipping.setLastName(order.getShippingLastName()); - shipping.setCompanyName(order.getShippingCompanyName()); - shipping.setContactPerson(order.getShippingContactPerson()); - shipping.setAddressLine1(order.getShippingAddressLine1()); - shipping.setAddressLine2(order.getShippingAddressLine2()); - shipping.setZip(order.getShippingZip()); - shipping.setCity(order.getShippingCity()); - shipping.setCountryCode(order.getShippingCountryCode()); - dto.setShippingAddress(shipping); - } - - List itemDtos = items.stream().map(i -> { - OrderItemDto idto = new OrderItemDto(); - idto.setId(i.getId()); - idto.setOriginalFilename(i.getOriginalFilename()); - idto.setMaterialCode(i.getMaterialCode()); - idto.setColorCode(i.getColorCode()); - idto.setQuantity(i.getQuantity()); - idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); - idto.setMaterialGrams(i.getMaterialGrams()); - idto.setUnitPriceChf(i.getUnitPriceChf()); - idto.setLineTotalChf(i.getLineTotalChf()); - return idto; - }).toList(); - dto.setItems(itemDtos); - - return dto; - } - - private String getDisplayOrderNumber(Order order) { - String orderNumber = order.getOrderNumber(); - if (orderNumber != null && !orderNumber.isBlank()) { - return orderNumber; - } - return order.getId() != null ? order.getId().toString() : "unknown"; - } - - private ResponseEntity generateDocument(Order order, boolean isConfirmation) { - String displayOrderNumber = getDisplayOrderNumber(order); - if (isConfirmation) { - Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber); - try { - byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(existingPdf); - } catch (Exception ignored) { - // fallback to generated confirmation document - } - } - - List items = orderItemRepo.findByOrder_Id(order.getId()); - Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null); - byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); - - String prefix = isConfirmation ? "confirmation-" : "invoice-"; - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(pdf); - } - - private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { - try { - Path candidate = Path.of(storedRelativePath).normalize(); - if (candidate.isAbsolute()) { - return null; - } - Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); - if (!candidate.startsWith(expectedPrefix)) { - return null; - } - return candidate; - } catch (InvalidPathException e) { - return null; - } - } - - 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) { - return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); + return adminOrderControllerService.downloadOrderInvoice(orderId); } } diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java new file mode 100644 index 0000000..22327e0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -0,0 +1,423 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.dto.OrderItemDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.event.OrderShippedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +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.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminOrderControllerService { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); + private static final List ALLOWED_ORDER_STATUSES = List.of( + "PENDING_PAYMENT", + "PAID", + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED", + "CANCELLED" + ); + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final PaymentRepository paymentRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final PaymentService paymentService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final ApplicationEventPublisher eventPublisher; + + public AdminOrderControllerService(OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + PaymentRepository paymentRepo, + QuoteLineItemRepository quoteLineItemRepo, + PaymentService paymentService, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + ApplicationEventPublisher eventPublisher) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.paymentRepo = paymentRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.paymentService = paymentService; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.eventPublisher = eventPublisher; + } + + public List listOrders() { + return orderRepo.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toOrderDto) + .toList(); + } + + public OrderDto getOrder(UUID orderId) { + return toOrderDto(getOrderOrThrow(orderId)); + } + + @Transactional + public OrderDto updatePaymentMethod(UUID orderId, Map payload) { + getOrderOrThrow(orderId); + String method = payload != null ? payload.get("method") : null; + if (method == null || method.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Payment method is required"); + } + paymentService.updatePaymentMethod(orderId, method); + return toOrderDto(getOrderOrThrow(orderId)); + } + + @Transactional + public OrderDto updateOrderStatus(UUID orderId, AdminOrderStatusUpdateRequest payload) { + if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Status is required"); + } + + Order order = getOrderOrThrow(orderId); + String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) + ); + } + String previousStatus = order.getStatus(); + order.setStatus(normalizedStatus); + Order savedOrder = orderRepo.save(order); + + if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) { + eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder)); + } + + return toOrderDto(savedOrder); + } + + public ResponseEntity downloadOrderItemFile(UUID orderId, UUID orderItemId) { + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found")); + + if (!item.getOrder().getId().equals(orderId)) { + throw new ResponseStatusException(NOT_FOUND, "Order item not found for order"); + } + + String relativePath = item.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (safeRelativePath == null) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + + try { + Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (item.getMimeType() != null && !item.getMimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(item.getMimeType()); + } catch (Exception ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank() + ? item.getOriginalFilename() + : "order-item-" + orderItemId; + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + } + + public ResponseEntity downloadOrderConfirmation(UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), true); + } + + public ResponseEntity downloadOrderInvoice(UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), false); + } + + private Order getOrderOrThrow(UUID orderId) { + return orderRepo.findById(orderId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); + } + + private OrderDto toOrderDto(Order order) { + List items = orderItemRepo.findByOrder_Id(order.getId()); + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { + dto.setPaymentStatus(payment.getStatus()); + dto.setPaymentMethod(payment.getMethod()); + }); + + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + QuoteSession sourceSession = order.getSourceQuoteSession(); + if (sourceSession != null) { + dto.setPrintMaterialCode(sourceSession.getMaterialCode()); + dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm()); + dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm()); + dto.setPrintInfillPattern(sourceSession.getInfillPattern()); + dto.setPrintInfillPercent(sourceSession.getInfillPercent()); + dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled()); + } + + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + + List itemDtos = items.stream().map(item -> { + OrderItemDto itemDto = new OrderItemDto(); + itemDto.setId(item.getId()); + itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setMaterialCode(item.getMaterialCode()); + itemDto.setColorCode(item.getColorCode()); + itemDto.setQuantity(item.getQuantity()); + itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); + itemDto.setMaterialGrams(item.getMaterialGrams()); + itemDto.setUnitPriceChf(item.getUnitPriceChf()); + itemDto.setLineTotalChf(item.getLineTotalChf()); + return itemDto; + }).toList(); + dto.setItems(itemDtos); + + return dto; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private ResponseEntity generateDocument(Order order, boolean isConfirmation) { + String displayOrderNumber = getDisplayOrderNumber(order); + if (isConfirmation) { + Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber); + try { + byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // fallback to generated confirmation document + } + } + + List items = orderItemRepo.findByOrder_Id(order.getId()); + Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null); + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + + String prefix = isConfirmation ? "confirmation-" : "invoice-"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } + + private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { + try { + Path candidate = Path.of(storedRelativePath).normalize(); + if (candidate.isAbsolute()) { + return null; + } + Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); + if (!candidate.startsWith(expectedPrefix)) { + return null; + } + return candidate; + } catch (InvalidPathException e) { + return null; + } + } + + 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(quoteItem -> targetFilename.equals(normalizeFilename(quoteItem.getOriginalFilename()))) + .sorted(Comparator.comparingInt((QuoteLineItem quoteItem) -> scoreQuoteMatch(orderItem, quoteItem)).reversed()) + .map(quoteItem -> resolveStoredQuotePath(quoteItem.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) { + return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java new file mode 100644 index 0000000..03d6163 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -0,0 +1,352 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.dto.OrderItemDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.payment.TwintPaymentService; +import com.printcalculator.service.storage.StorageService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class OrderControllerService { + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + private static final Set PERSONAL_DATA_REDACTED_STATUSES = Set.of( + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED" + ); + + private final OrderService orderService; + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final TwintPaymentService twintPaymentService; + private final PaymentService paymentService; + private final PaymentRepository paymentRepo; + + public OrderControllerService(OrderService orderService, + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + TwintPaymentService twintPaymentService, + PaymentService paymentService, + PaymentRepository paymentRepo) { + this.orderService = orderService; + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.twintPaymentService = twintPaymentService; + this.paymentService = paymentService; + this.paymentRepo = paymentRepo; + } + + @Transactional + public OrderDto createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) { + Order order = orderService.createOrderFromQuote(quoteSessionId, request); + List items = orderItemRepo.findByOrder_Id(order.getId()); + return convertToDto(order, items); + } + + @Transactional + public boolean uploadOrderItemFile(UUID orderId, UUID orderItemId, MultipartFile file) throws IOException { + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new RuntimeException("OrderItem not found")); + + if (!item.getOrder().getId().equals(orderId)) { + return false; + } + + String relativePath = item.getStoredRelativePath(); + Path destinationRelativePath; + if (relativePath == null || relativePath.equals("PENDING")) { + String ext = getExtension(file.getOriginalFilename()); + String storedFilename = UUID.randomUUID() + "." + ext; + destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); + item.setStoredRelativePath(destinationRelativePath.toString()); + item.setStoredFilename(storedFilename); + } else { + destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (destinationRelativePath == null) { + return false; + } + } + + storageService.store(file, destinationRelativePath); + item.setFileSizeBytes(file.getSize()); + item.setMimeType(file.getContentType()); + orderItemRepo.save(item); + + return true; + } + + public Optional getOrder(UUID orderId) { + return orderRepo.findById(orderId) + .map(order -> { + List items = orderItemRepo.findByOrder_Id(order.getId()); + return convertToDto(order, items); + }); + } + + @Transactional + public Optional reportPayment(UUID orderId, String method) { + paymentService.reportPayment(orderId, method); + return getOrder(orderId); + } + + public ResponseEntity getConfirmation(UUID orderId) { + return generateDocument(orderId, true); + } + + public ResponseEntity> getTwintPayment(UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + byte[] qrPng = twintPaymentService.generateQrPng(order, 360); + String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); + + Map data = new HashMap<>(); + data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order)); + data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); + data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); + data.put("qrImageDataUri", qrDataUri); + return ResponseEntity.ok(data); + } + + public ResponseEntity openTwintPayment(UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.status(302) + .location(URI.create(twintPaymentService.getTwintPaymentUrl(order))) + .build(); + } + + public ResponseEntity getTwintQr(UUID orderId, int size) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + int normalizedSize = Math.max(200, Math.min(size, 600)); + byte[] png = twintPaymentService.generateQrPng(order, normalizedSize); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .body(png); + } + + private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found")); + + if (isConfirmation) { + Path relativePath = buildConfirmationPdfRelativePath(order); + try { + byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // Fallback to on-the-fly generation if the stored file is missing or unreadable. + } + } + + List items = orderItemRepo.findByOrder_Id(orderId); + Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); + + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; + String truncatedUuid = order.getId().toString().substring(0, 8); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } + + private Path buildConfirmationPdfRelativePath(Order order) { + return Path.of( + "orders", + order.getId().toString(), + "documents", + "confirmation-" + getDisplayOrderNumber(order) + ".pdf" + ); + } + + private String getExtension(String filename) { + if (filename == null) { + return "stl"; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "stl"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "stl"; + } + + private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { + try { + Path candidate = Path.of(storedRelativePath).normalize(); + if (candidate.isAbsolute()) { + return null; + } + + Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); + if (!candidate.startsWith(expectedPrefix)) { + return null; + } + + return candidate; + } catch (InvalidPathException e) { + return null; + } + } + + private OrderDto convertToDto(Order order, List items) { + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { + dto.setPaymentStatus(payment.getStatus()); + dto.setPaymentMethod(payment.getMethod()); + }); + + boolean redactPersonalData = shouldRedactPersonalData(order.getStatus()); + if (!redactPersonalData) { + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + } + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + + if (!redactPersonalData) { + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + } + + List itemDtos = items.stream().map(item -> { + OrderItemDto itemDto = new OrderItemDto(); + itemDto.setId(item.getId()); + itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setMaterialCode(item.getMaterialCode()); + itemDto.setColorCode(item.getColorCode()); + itemDto.setQuality(item.getQuality()); + itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); + itemDto.setLayerHeightMm(item.getLayerHeightMm()); + itemDto.setInfillPercent(item.getInfillPercent()); + itemDto.setInfillPattern(item.getInfillPattern()); + itemDto.setSupportsEnabled(item.getSupportsEnabled()); + itemDto.setQuantity(item.getQuantity()); + itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); + itemDto.setMaterialGrams(item.getMaterialGrams()); + itemDto.setUnitPriceChf(item.getUnitPriceChf()); + itemDto.setLineTotalChf(item.getLineTotalChf()); + return itemDto; + }).collect(Collectors.toList()); + dto.setItems(itemDtos); + + 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) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } +} diff --git a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java index 5849c12..a15b5af 100644 --- a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java +++ b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java @@ -2,14 +2,12 @@ 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.order.OrderControllerService; import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.QrBillService; import com.printcalculator.service.storage.StorageService; @@ -41,12 +39,6 @@ class OrderControllerPrivacyTest { @Mock private OrderItemRepository orderItemRepo; @Mock - private QuoteSessionRepository quoteSessionRepo; - @Mock - private QuoteLineItemRepository quoteLineItemRepo; - @Mock - private CustomerRepository customerRepo; - @Mock private StorageService storageService; @Mock private InvoicePdfRenderingService invoiceService; @@ -63,13 +55,10 @@ class OrderControllerPrivacyTest { @BeforeEach void setUp() { - controller = new OrderController( + OrderControllerService orderControllerService = new OrderControllerService( orderService, orderRepo, orderItemRepo, - quoteSessionRepo, - quoteLineItemRepo, - customerRepo, storageService, invoiceService, qrBillService, @@ -77,6 +66,7 @@ class OrderControllerPrivacyTest { paymentService, paymentRepo ); + controller = new OrderController(orderControllerService); } @Test diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java index 804da16..e20e4e3 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -6,6 +6,8 @@ import com.printcalculator.entity.Order; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.order.AdminOrderControllerService; import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.QrBillService; @@ -41,6 +43,8 @@ class AdminOrderControllerStatusValidationTest { @Mock private PaymentRepository paymentRepository; @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock private PaymentService paymentService; @Mock private StorageService storageService; @@ -55,16 +59,18 @@ class AdminOrderControllerStatusValidationTest { @BeforeEach void setUp() { - controller = new AdminOrderController( + AdminOrderControllerService adminOrderControllerService = new AdminOrderControllerService( orderRepository, orderItemRepository, paymentRepository, + quoteLineItemRepository, paymentService, storageService, invoicePdfRenderingService, qrBillService, eventPublisher ); + controller = new AdminOrderController(adminOrderControllerService); } @Test diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 8fccbf1..5c3b16f 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -63,11 +63,11 @@ {{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitWeight | number: "1.0-0" }}g | - materiale: {{ item.material || "N/D" }} - @if (getItemDifferenceLabel(item.fileName)) { + {{ item.material || "N/D" }} + @if (getItemDifferenceLabel(item.fileName, item.material)) { | - {{ getItemDifferenceLabel(item.fileName) }} + {{ getItemDifferenceLabel(item.fileName, item.material) }} } @@ -110,7 +110,7 @@
- + {{ "QUOTE.CONSULT" | translate }}
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 8218937..2ed738c 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -20,10 +20,11 @@ display: flex; justify-content: space-between; align-items: center; - padding: var(--space-3); - background: var(--color-neutral-50); + padding: var(--space-3) var(--space-4); + background: var(--color-bg-card); border-radius: var(--radius-md); border: 1px solid var(--color-border); + box-shadow: 0 2px 6px rgba(10, 20, 30, 0.04); } .item-info { @@ -54,6 +55,19 @@ color: var(--color-text-muted); } +.material-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #d9d4bd; + background: #fbf7e9; + color: #6d5b1d; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.2px; +} + .item-controls { display: flex; align-items: center; @@ -149,6 +163,7 @@ .actions-right { display: flex; align-items: center; + gap: var(--space-2); } .actions-right { diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 2aad331..3e1c30c 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -189,14 +189,30 @@ export class QuoteResultComponent implements OnDestroy { this.quantityTimers.clear(); } - getItemDifferenceLabel(fileName: string): string { + getItemDifferenceLabel(fileName: string, materialCode?: string): string { const differences = this.itemSettingsDiffByFileName()[fileName]?.differences || []; if (differences.length === 0) return ''; - const materialOnly = differences.find( + const normalizedMaterial = String(materialCode || '') + .trim() + .toLowerCase(); + + const filtered = differences.filter((entry) => { + const normalized = String(entry || '') + .trim() + .toLowerCase(); + const isMaterialOnly = !normalized.includes(':'); + return !(isMaterialOnly && normalized === normalizedMaterial); + }); + + if (filtered.length === 0) { + return ''; + } + + const materialOnly = filtered.find( (entry) => !entry.includes(':') && entry.trim().length > 0, ); - return materialOnly || differences.join(' | '); + return materialOnly || filtered.join(' | '); } } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 6fce66e..843fb2e 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -13,11 +13,9 @@ > } -
} - @if (items().length === 0) { } - @if (items().length > 0) {
@for (item of items(); track item.file.name; let i = $index) { @@ -83,7 +80,6 @@ }
-
.

- + @if (mode() === "advanced") { +
+ +
- @if (sameSettingsForAll()) { -
-

Impostazioni globali

+ @if (sameSettingsForAll()) { +
+

Impostazioni globali

-
- - - @if (mode() === "easy") { +
- } @else { + - } -
+
- @if (mode() === "advanced") {
- } @else { - @if (getSelectedItem(); as selectedItem) { -
-

- Impostazioni file: {{ selectedItem.file.name }} -

- -
- - - @if (mode() === "easy") { - - } @else { - - }
- @if (mode() === "easy") { - - } @else { - - } -
+ } @else { + @if (getSelectedItem(); as selectedItem) { +
+

+ Impostazioni file: {{ selectedItem.file.name }} +

- @if (items().length > 1) { -
- - -
- } +
+ - @if (mode() === "advanced") { -
- + +
- -
+
+ -
- + +
- +
+ + + +
} -
} } } @@ -349,7 +258,6 @@ @if (items().length === 0 && form.get("itemsTouched")?.value) {
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
} -
- @if (loading() && uploadProgress() < 100) {
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss index f3350cd..fd36719 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss @@ -2,8 +2,8 @@ margin-bottom: var(--space-6); } .upload-privacy-note { - margin-top: var(--space-6); - margin-bottom: 0; + margin-top: var(--space-4); + margin-bottom: var(--space-1); font-size: 0.8rem; color: var(--color-text-muted); text-align: left; @@ -35,48 +35,50 @@ /* Grid Layout for Files */ .items-grid { display: grid; - grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */ - gap: var(--space-2); /* Tighten gap for mobile */ + grid-template-columns: 1fr; + gap: var(--space-3); margin-top: var(--space-4); margin-bottom: var(--space-4); @media (min-width: 640px) { + grid-template-columns: 1fr 1fr; gap: var(--space-3); } } .file-card { - padding: var(--space-2); /* Reduced from space-3 */ - background: var(--color-neutral-100); + padding: var(--space-3); + background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-md); transition: all 0.2s; cursor: pointer; display: flex; flex-direction: column; - gap: 4px; /* Reduced gap */ - position: relative; /* For absolute positioning of remove btn */ - min-width: 0; /* Allow flex item to shrink below content size if needed */ + gap: var(--space-2); + position: relative; + min-width: 0; &:hover { border-color: var(--color-neutral-300); + box-shadow: 0 4px 10px rgba(10, 20, 30, 0.07); } &.active { border-color: var(--color-brand); - background: rgba(250, 207, 10, 0.05); + background: rgba(250, 207, 10, 0.08); box-shadow: 0 0 0 1px var(--color-brand); } } .card-header { overflow: hidden; - padding-right: 25px; /* Adjusted */ - margin-bottom: 2px; + padding-right: 28px; + margin-bottom: 0; } .file-name { - font-weight: 500; - font-size: 0.8rem; /* Smaller font */ + font-weight: 600; + font-size: 0.92rem; color: var(--color-text); display: block; white-space: nowrap; @@ -92,47 +94,46 @@ .card-controls { display: flex; - align-items: flex-end; /* Align bottom of input and color circle */ - gap: 16px; /* Space between Qty and Color */ + align-items: flex-end; + gap: var(--space-4); width: 100%; } .qty-group, .color-group { display: flex; - flex-direction: column; /* Stack label and input */ + flex-direction: column; align-items: flex-start; - gap: 0px; + gap: 2px; label { - font-size: 0.6rem; + font-size: 0.72rem; color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.3px; font-weight: 600; - margin-bottom: 2px; + margin-bottom: 0; } } .color-group { - align-items: flex-start; /* Align label left */ - /* margin-right removed */ + align-items: flex-start; - /* Override margin in selector for this context */ ::ng-deep .color-selector-container { margin-left: 0; } } .qty-input { - width: 36px; /* Slightly smaller */ - padding: 1px 2px; + width: 54px; + padding: 4px 6px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); text-align: center; - font-size: 0.85rem; + font-size: 0.95rem; + font-weight: 600; background: white; - height: 24px; /* Explicit height to match color circle somewhat */ + height: 34px; &:focus { outline: none; border-color: var(--color-brand); @@ -141,10 +142,10 @@ .btn-remove { position: absolute; - top: 4px; - right: 4px; - width: 18px; - height: 18px; + top: 6px; + right: 6px; + width: 20px; + height: 20px; border-radius: 4px; border: none; background: transparent; @@ -155,7 +156,7 @@ align-items: center; justify-content: center; transition: all 0.2s; - font-size: 0.8rem; + font-size: 0.9rem; &:hover { background: var(--color-danger-100); @@ -170,7 +171,7 @@ .btn-add-more { width: 100%; - padding: var(--space-3); + padding: 0.75rem var(--space-3); background: var(--color-neutral-800); color: white; border: none; @@ -193,6 +194,50 @@ } } +.sync-settings { + margin-top: var(--space-4); + margin-bottom: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-50); + padding: var(--space-3); +} + +.sync-settings-toggle { + display: flex; + align-items: flex-start; + gap: var(--space-3); + cursor: pointer; + + input[type="checkbox"] { + width: 20px; + height: 20px; + margin-top: 2px; + accent-color: var(--color-brand); + flex-shrink: 0; + } +} + +.sync-settings-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sync-settings-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; +} + +.sync-settings-subtitle { + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-muted); + line-height: 1.35; +} + .checkbox-row { display: flex; align-items: center; diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index d608ce6..1fdd614 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -22,6 +22,7 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component'; import { QuoteRequest, + QuoteRequestItem, QuoteEstimatorService, OptionsResponse, SimpleOption, @@ -34,33 +35,33 @@ interface FormItem { file: File; previewFile?: File; quantity: number; - material?: string; - quality?: string; - color: string; - filamentVariantId?: number; - supportEnabled?: boolean; - infillDensity?: number; - infillPattern?: string; - layerHeight?: number; - nozzleDiameter?: number; - printSettings: ItemPrintSettings; -} - -interface ItemPrintSettings { material: string; quality: string; - nozzleDiameter: number; - layerHeight: number; + color: string; + filamentVariantId?: number; + supportEnabled: boolean; infillDensity: number; infillPattern: string; - supportEnabled: boolean; + layerHeight: number; + nozzleDiameter: number; } interface ItemSettingsDiffInfo { differences: string[]; } -type ItemPrintSettingsUpdate = Partial; +type ItemPrintSettingsUpdate = Partial< + Pick< + FormItem, + | 'material' + | 'quality' + | 'nozzleDiameter' + | 'layerHeight' + | 'infillDensity' + | 'infillPattern' + | 'supportEnabled' + > +>; @Component({ selector: 'app-upload-form', @@ -83,6 +84,7 @@ export class UploadFormComponent implements OnInit { lockedSettings = input(false); loading = input(false); uploadProgress = input(0); + submitRequest = output(); itemQuantityChange = output<{ index: number; @@ -109,37 +111,148 @@ export class UploadFormComponent implements OnInit { items = signal([]); selectedFile = signal(null); + sameSettingsForAll = signal(true); - // Dynamic Options materials = signal([]); qualities = signal([]); nozzleDiameters = signal([]); infillPatterns = signal([]); layerHeights = signal([]); + currentMaterialVariants = signal([]); - // Store full material options to lookup variants/colors if needed later private fullMaterialOptions: MaterialOption[] = []; private allLayerHeights: SimpleOption[] = []; private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; - sameSettingsForAll = signal(true); - - // Computed variants for valid material - currentMaterialVariants = signal([]); - - private updateVariants() { - const matCode = this.form.get('material')?.value; - if (matCode && this.fullMaterialOptions.length > 0) { - const found = this.fullMaterialOptions.find((m) => m.code === matCode); - this.currentMaterialVariants.set(found ? found.variants : []); - this.syncSelectedItemVariantSelection(); - } else { - this.currentMaterialVariants.set([]); - } - } acceptedFormats = '.stl,.3mf,.step,.stp'; + constructor() { + this.form = this.fb.group({ + itemsTouched: [false], + syncAllItems: [true], + material: ['', Validators.required], + quality: ['standard', Validators.required], + notes: [''], + infillDensity: [15, [Validators.min(0), Validators.max(100)]], + layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], + nozzleDiameter: [0.4, Validators.required], + infillPattern: ['grid', Validators.required], + supportEnabled: [false], + }); + + this.form.get('material')?.valueChanges.subscribe((value) => { + this.updateVariants(String(value || '')); + }); + + this.form.get('quality')?.valueChanges.subscribe((quality) => { + if (this.isPatchingSettings || this.mode() !== 'easy') { + return; + } + this.applyEasyPresetFromQuality(String(quality || 'standard')); + }); + + this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => { + if (this.isPatchingSettings) { + return; + } + this.updateLayerHeightOptionsForNozzle(nozzle, true); + }); + + this.form.valueChanges.subscribe(() => { + if (this.isPatchingSettings) { + return; + } + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } else { + this.syncSelectedItemSettingsFromForm(); + } + + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + }); + + effect(() => { + this.applySettingsLock(this.lockedSettings()); + }); + + effect(() => { + if (this.mode() !== 'easy' || this.sameSettingsForAll()) { + return; + } + + this.sameSettingsForAll.set(true); + this.form.get('syncAllItems')?.setValue(true, { emitEvent: false }); + this.applyGlobalSettingsToAllItems(); + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + }); + } + + ngOnInit() { + this.estimator.getOptions().subscribe({ + next: (options: OptionsResponse) => { + this.fullMaterialOptions = options.materials || []; + + this.materials.set( + (options.materials || []).map((m) => ({ label: m.label, value: m.code })), + ); + this.qualities.set( + (options.qualities || []).map((q) => ({ label: q.label, value: q.id })), + ); + this.infillPatterns.set( + (options.infillPatterns || []).map((p) => ({ label: p.label, value: p.id })), + ); + this.nozzleDiameters.set( + (options.nozzleDiameters || []).map((n) => ({ label: n.label, value: n.value })), + ); + + this.allLayerHeights = (options.layerHeights || []).map((l) => ({ + 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.setDefaults(); + }, + error: (err) => { + console.error('Failed to load options', err); + this.materials.set([ + { + label: this.translate.instant('CALC.FALLBACK_MATERIAL'), + value: 'PLA', + }, + ]); + this.qualities.set([ + { + label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), + value: 'standard', + }, + ]); + this.infillPatterns.set([{ label: 'Grid', value: 'grid' }]); + this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); + + this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }]; + this.layerHeightsByNozzle = { + [this.toNozzleKey(0.4)]: this.allLayerHeights, + }; + + this.setDefaults(); + }, + }); + } + isStlFile(file: File | null): boolean { if (!file) return false; const name = file.name.toLowerCase(); @@ -154,8 +267,7 @@ export class UploadFormComponent implements OnInit { const selected = this.selectedFile(); if (!selected) return null; const item = this.items().find((i) => i.file === selected); - if (!item) return null; - return item.previewFile ?? item.file; + return item ? item.previewFile || item.file : null; } getSelectedItemIndex(): number { @@ -167,290 +279,107 @@ export class UploadFormComponent implements OnInit { getSelectedItem(): FormItem | null { const index = this.getSelectedItemIndex(); if (index < 0) return null; - return this.items()[index] ?? 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 ?? []; - } + const normalized = String(materialCode || '').trim().toUpperCase(); + if (!normalized) return []; - constructor() { - this.form = this.fb.group({ - itemsTouched: [false], // Hack to track touched state for custom items list - syncAllItems: [true], - material: ['', Validators.required], - quality: ['', Validators.required], - items: [[]], // Track items in form for validation if needed - notes: [''], - // Advanced fields - infillDensity: [15, [Validators.min(0), Validators.max(100)]], - layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], - nozzleDiameter: [0.4, Validators.required], - infillPattern: ['grid'], - supportEnabled: [false], - }); - - // Listen to material changes to update variants and propagate when "all files equal" is active. - this.form.get('material')?.valueChanges.subscribe((materialCode) => { - this.updateVariants(); - if (this.sameSettingsForAll() && !this.isPatchingSettings) { - this.applyGlobalMaterialToAll(String(materialCode || 'PLA')); - } - }); - - this.form.get('quality')?.valueChanges.subscribe((quality) => { - if (this.mode() !== 'easy' || this.isPatchingSettings) return; - 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(() => { - this.applySettingsLock(this.lockedSettings()); - }); - } - - private applyAdvancedPresetFromQuality(quality: string | null | undefined) { - const normalized = (quality || 'standard').toLowerCase(); - - const presets: Record< - string, - { - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - } - > = { - standard: { - nozzleDiameter: 0.4, - layerHeight: 0.2, - infillDensity: 15, - infillPattern: 'grid', - }, - extra_fine: { - nozzleDiameter: 0.4, - layerHeight: 0.12, - infillDensity: 20, - infillPattern: 'grid', - }, - high: { - nozzleDiameter: 0.4, - layerHeight: 0.12, - infillDensity: 20, - infillPattern: 'grid', - }, // Legacy alias - draft: { - nozzleDiameter: 0.4, - layerHeight: 0.24, - infillDensity: 12, - infillPattern: 'grid', - }, - }; - - const preset = presets[normalized] || presets['standard']; - this.form.patchValue(preset, { emitEvent: false }); - this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); - } - - ngOnInit() { - this.estimator.getOptions().subscribe({ - next: (options: OptionsResponse) => { - this.fullMaterialOptions = options.materials; - this.updateVariants(); // Trigger initial update - - this.materials.set( - options.materials.map((m) => ({ label: m.label, value: m.code })), - ); - this.qualities.set( - options.qualities.map((q) => ({ label: q.label, value: q.id })), - ); - this.infillPatterns.set( - options.infillPatterns.map((p) => ({ label: p.label, value: p.id })), - ); - this.allLayerHeights = options.layerHeights.map((l) => ({ - 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( - options.nozzleDiameters.map((n) => ({ - label: n.label, - value: n.value, - })), - ); - - this.setDefaults(); - }, - error: (err) => { - console.error('Failed to load options', err); - // Fallback for debugging/offline dev - this.materials.set([ - { - label: this.translate.instant('CALC.FALLBACK_MATERIAL'), - value: 'PLA', - }, - ]); - this.qualities.set([ - { - label: this.translate.instant('CALC.FALLBACK_QUALITY_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.setDefaults(); - }, - }); - } - - private setDefaults() { - // Set Defaults if available - if (this.materials().length > 0 && !this.form.get('material')?.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) { - // Try to find 'standard' or use first - const std = this.qualities().find((q) => q.value === 'standard'); - this.form - .get('quality') - ?.setValue(std ? std.value : this.qualities()[0].value); - } - if ( - this.nozzleDiameters().length > 0 && - !this.form.get('nozzleDiameter')?.value - ) { - this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 - } - - this.updateLayerHeightOptionsForNozzle( - this.form.get('nozzleDiameter')?.value, - true, + const found = this.fullMaterialOptions.find( + (m) => String(m.code || '').trim().toUpperCase() === normalized, ); + return found?.variants || []; + } - if ( - this.infillPatterns().length > 0 && - !this.form.get('infillPattern')?.value - ) { - this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); + getLayerHeightOptionsForNozzle(nozzleRaw: unknown): SimpleOption[] { + const key = this.toNozzleKey(nozzleRaw); + const perNozzle = this.layerHeightsByNozzle[key]; + if (perNozzle && perNozzle.length > 0) { + return perNozzle; } - - this.emitPrintSettingsChange(); + return this.allLayerHeights.length > 0 + ? this.allLayerHeights + : [{ label: '0.20 mm', value: 0.2 }]; } onFilesDropped(newFiles: File[]) { - const MAX_SIZE = 200 * 1024 * 1024; // 200MB + const MAX_SIZE = 200 * 1024 * 1024; const validItems: FormItem[] = []; let hasError = false; + const defaults = this.getCurrentGlobalItemDefaults(); for (const file of newFiles) { if (file.size > MAX_SIZE) { hasError = true; - } else { - const defaultSelection = this.getDefaultVariantSelection(defaults.material); - validItems.push({ - file, - previewFile: this.isStlFile(file) ? file : undefined, - quantity: 1, - material: defaults.material, - quality: defaults.quality, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId, - supportEnabled: defaults.supportEnabled, - infillDensity: defaults.infillDensity, - infillPattern: defaults.infillPattern, - layerHeight: defaults.layerHeight, - nozzleDiameter: defaults.nozzleDiameter, - printSettings: this.getCurrentItemPrintSettings(), - }); + continue; } + + const selection = this.getDefaultVariantSelection(defaults.material); + validItems.push({ + file, + previewFile: this.isStlFile(file) ? file : undefined, + quantity: 1, + material: defaults.material, + quality: defaults.quality, + color: selection.colorName, + filamentVariantId: selection.filamentVariantId, + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + }); } if (hasError) { alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); } - if (validItems.length > 0) { - this.items.update((current) => [...current, ...validItems]); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectFile(validItems[validItems.length - 1].file); - this.emitItemSettingsDiffChange(); + if (validItems.length === 0) { + return; } + + this.items.update((current) => [...current, ...validItems]); + this.form.get('itemsTouched')?.setValue(true); + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } + + this.selectFile(validItems[validItems.length - 1].file); + this.emitItemSettingsDiffChange(); } onAdditionalFilesSelected(event: Event) { const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - this.onFilesDropped(Array.from(input.files)); - // Reset input so same files can be selected again if needed - input.value = ''; + if (!input.files || input.files.length === 0) { + return; } + + this.onFilesDropped(Array.from(input.files)); + input.value = ''; + } + + updateItemQuantity(index: number, event: Event) { + const input = event.target as HTMLInputElement; + const parsed = parseInt(input.value, 10); + 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.itemQuantityChange.emit({ + index, + fileName: currentItem.file.name, + quantity: normalizedQty, + }); } updateItemQuantityByIndex(index: number, quantity: number) { @@ -459,75 +388,71 @@ export class UploadFormComponent implements OnInit { 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, quantity: normalizedQty }; - }); + + if (this.sameSettingsForAll()) { + return current.map((item) => ({ ...item, quantity: normalizedQty })); + } + + return current.map((item, idx) => + idx === index ? { ...item, quantity: normalizedQty } : item, + ); }); } updateItemQuantityByName(fileName: string, quantity: number) { const targetName = this.normalizeFileName(fileName); const normalizedQty = this.normalizeQuantity(quantity); - const applyToAll = this.sameSettingsForAll(); this.items.update((current) => { let matched = false; + return current.map((item) => { - if (applyToAll) { + if (this.sameSettingsForAll()) { return { ...item, quantity: normalizedQty }; } + if (!matched && this.normalizeFileName(item.file.name) === targetName) { matched = true; return { ...item, quantity: normalizedQty }; } + return item; }); }); } selectFile(file: File) { - if (this.selectedFile() === file) { - // toggle off? no, keep active - } else { + if (this.selectedFile() !== file) { this.selectedFile.set(file); } this.loadSelectedItemSettingsIntoForm(); } - // Helper to get color of currently selected file getSelectedFileColor(): string { - const file = this.selectedFile(); - if (!file) return '#facf0a'; // Default - - const item = this.items().find((i) => i.file === file); - if (item) { - const vars = this.getVariantsForMaterial(item.material); - if (vars && vars.length > 0) { - const found = item.filamentVariantId - ? vars.find((v) => v.id === item.filamentVariantId) - : vars.find((v) => v.colorName === item.color); - if (found) return found.hexColor; - } - return getColorHex(item.color); + const selected = this.selectedFile(); + if (!selected) { + return '#facf0a'; } - return '#facf0a'; - } - updateItemQuantity(index: number, event: Event) { - const input = event.target as HTMLInputElement; - const parsed = parseInt(input.value, 10); - 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.itemQuantityChange.emit({ - index, - fileName: currentItem.file.name, - quantity: normalizedQty, - }); + const item = this.items().find((i) => i.file === selected); + if (!item) { + return '#facf0a'; + } + + const variants = this.getVariantsForMaterial(item.material); + if (variants.length > 0) { + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor; + if (selectedVariant) { + return selectedVariant.hexColor; + } + } + + return getColorHex(item.color); } updateItemColor( @@ -537,464 +462,734 @@ export class UploadFormComponent implements OnInit { const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName; const filamentVariantId = - typeof newSelection === 'string' - ? undefined - : newSelection.filamentVariantId; - this.items.update((current) => { - const updated = [...current]; - const applyToAll = this.sameSettingsForAll(); - return updated.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { - ...item, - color: colorName, - filamentVariantId, - }; - }); - }); - } - - 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]; + typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId; 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; - }); + if (index < 0 || index >= current.length) { + return current; + } + + return current.map((item, idx) => + idx === index + ? { + ...item, + color: colorName, + filamentVariantId, + } + : item, + ); }); } - 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 = { - 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) { - if (!Number.isInteger(index) || index < 0) return; - const normalizedPatch: Partial = { ...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) { let nextSelected: File | null = null; + this.items.update((current) => { const updated = [...current]; const removed = updated.splice(index, 1)[0]; - if (this.selectedFile() === removed.file) { - nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null; + if (!removed) { + return current; } + + if (this.selectedFile() === removed.file) { + nextSelected = + updated.length > 0 ? updated[Math.max(0, index - 1)].file : null; + } + return updated; }); + if (nextSelected) { this.selectFile(nextSelected); } else if (this.items().length === 0) { this.selectedFile.set(null); } + this.emitItemSettingsDiffChange(); } - setFiles(files: File[]) { - const validItems: FormItem[] = []; - const defaults = this.getCurrentGlobalItemDefaults(); - const defaultSelection = this.getDefaultVariantSelection(defaults.material); - for (const file of files) { - validItems.push({ - file, - previewFile: this.isStlFile(file) ? file : undefined, - quantity: 1, - material: defaults.material, - quality: defaults.quality, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId, - supportEnabled: defaults.supportEnabled, - infillDensity: defaults.infillDensity, - infillPattern: defaults.infillPattern, - layerHeight: defaults.layerHeight, - nozzleDiameter: defaults.nozzleDiameter, - }); + onSameSettingsToggle(enabled: boolean) { + this.sameSettingsForAll.set(enabled); + this.form.get('syncAllItems')?.setValue(enabled, { emitEvent: false }); + + if (enabled) { + this.applyGlobalSettingsToAllItems(); + } else { + this.loadSelectedItemSettingsIntoForm(); } - if (validItems.length > 0) { - this.items.set(validItems); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectFile(validItems[validItems.length - 1].file); - this.emitItemSettingsDiffChange(); - } - } - - setPreviewFileByIndex(index: number, previewFile: File) { - if (!Number.isInteger(index) || index < 0) return; - this.items.update((current) => { - if (index >= current.length) return current; - const updated = [...current]; - updated[index] = { ...updated[index], previewFile }; - return updated; - }); - } - - private getCurrentGlobalItemDefaults(): Omit & { - 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; - filamentVariantId?: number; - } { - const vars = materialCode - ? this.getVariantsForMaterial(materialCode) - : this.currentMaterialVariants(); - if (vars && vars.length > 0) { - const preferred = vars.find((v) => !v.isOutOfStock) || vars[0]; - return { - colorName: preferred.colorName, - filamentVariantId: preferred.id, - }; - } - return { colorName: 'Black' }; - } - - 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(); - if (!vars || vars.length === 0) { - return; - } - - const selected = this.selectedFile(); - if (!selected) { - return; - } - - const fallback = vars.find((v) => !v.isOutOfStock) || vars[0]; - this.items.update((current) => - current.map((item) => { - if (item.file !== selected) { - return item; - } - const byId = - item.filamentVariantId != null - ? vars.find((v) => v.id === item.filamentVariantId) - : null; - const byColor = vars.find((v) => v.colorName === item.color); - const selectedVariant = byId || byColor || fallback; - return { - ...item, - color: selectedVariant.colorName, - filamentVariantId: selectedVariant.id, - }; - }), - ); + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); } patchSettings(settings: any) { if (!settings) return; - // settings object matches keys in our form? - // Session has: materialCode, etc. derived from QuoteSession entity properties - // We need to map them if names differ. const patch: any = {}; if (settings.materialCode) patch.material = settings.materialCode; - // Heuristic for Quality if not explicitly stored as "draft/standard/high" - // But we stored it in session creation? - // QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill. - // So we might need to deduce it or just set Custom/Advanced. - // But for Easy mode, we want to show "Standard" etc. - - // Actually, let's look at what we have in QuoteSession. - // layerHeightMm, infillPercent, etc. - // If we are in Easy mode, we might just set the "quality" dropdown to match approx? - // Or if we stored "quality" in notes or separate field? We didn't. - - // Let's try to reverse map or defaults. - if (settings.layerHeightMm) { - if (settings.layerHeightMm >= 0.24) patch.quality = 'draft'; - else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine'; - else patch.quality = 'standard'; - - patch.layerHeight = settings.layerHeightMm; + const layer = Number(settings.layerHeightMm); + if (Number.isFinite(layer)) { + patch.layerHeight = layer; + patch.quality = + layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'; } - if (settings.nozzleDiameterMm) - patch.nozzleDiameter = settings.nozzleDiameterMm; - if (settings.infillPercent) patch.infillDensity = settings.infillPercent; + const nozzle = Number(settings.nozzleDiameterMm); + if (Number.isFinite(nozzle)) patch.nozzleDiameter = nozzle; + + const infill = Number(settings.infillPercent); + if (Number.isFinite(infill)) patch.infillDensity = infill; + if (settings.infillPattern) patch.infillPattern = settings.infillPattern; if (settings.supportsEnabled !== undefined) - patch.supportEnabled = settings.supportsEnabled; + patch.supportEnabled = Boolean(settings.supportsEnabled); if (settings.notes) patch.notes = settings.notes; this.isPatchingSettings = true; this.form.patchValue(patch, { emitEvent: false }); this.isPatchingSettings = false; + + this.updateVariants(String(this.form.get('material')?.value || '')); this.updateLayerHeightOptionsForNozzle( this.form.get('nozzleDiameter')?.value, true, ); + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } else { + this.syncSelectedItemSettingsFromForm(); + } + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + } + + setFiles(files: File[]) { + const defaults = this.getCurrentGlobalItemDefaults(); + const selection = this.getDefaultVariantSelection(defaults.material); + + const validItems: FormItem[] = files.map((file) => ({ + file, + previewFile: this.isStlFile(file) ? file : undefined, + quantity: 1, + material: defaults.material, + quality: defaults.quality, + color: selection.colorName, + filamentVariantId: selection.filamentVariantId, + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + })); + + this.items.set(validItems); + + if (validItems.length > 0) { + this.form.get('itemsTouched')?.setValue(true); + this.selectFile(validItems[validItems.length - 1].file); + } else { + this.selectedFile.set(null); + } + + this.emitItemSettingsDiffChange(); + } + + setPreviewFileByIndex(index: number, previewFile: File) { + if (!Number.isInteger(index) || index < 0) return; + + this.items.update((current) => { + if (index >= current.length) return current; + return current.map((item, idx) => + idx === index ? { ...item, previewFile } : item, + ); + }); + } + + setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) { + if (!Number.isInteger(index) || index < 0) return; + + this.items.update((current) => { + if (index >= current.length) return current; + + return current.map((item, idx) => { + if (idx !== index) { + return item; + } + + let next: FormItem = { + ...item, + ...update, + }; + + if (update.quality !== undefined) { + next.quality = this.normalizeQualityValue(update.quality); + } + + if (update.material !== undefined) { + const variants = this.getVariantsForMaterial(update.material); + const byId = + next.filamentVariantId != null + ? variants.find((v) => v.id === next.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === next.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const variant = byId || byColor || fallback; + if (variant) { + next = { + ...next, + color: variant.colorName, + filamentVariantId: variant.id, + }; + } + } + + return next; + }); + }); + + this.refreshSameSettingsFlag(); + + if (!this.sameSettingsForAll() && this.getSelectedItemIndex() === index) { + this.loadSelectedItemSettingsIntoForm(); + this.emitPrintSettingsChange(); + } + + this.emitItemSettingsDiffChange(); + } + + getCurrentRequestDraft(): QuoteRequest { + const defaults = this.getCurrentGlobalItemDefaults(); + + const items: QuoteRequestItem[] = this.items().map((item) => + this.toRequestItem(item, defaults), + ); + + return { + items, + material: defaults.material, + quality: defaults.quality, + notes: this.form.get('notes')?.value || '', + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + mode: this.mode(), + }; } onSubmit() { - console.log('UploadFormComponent: onSubmit triggered'); - console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); - - if (this.form.valid && this.items().length > 0) { - const items = this.items(); - const firstItemMaterial = items[0]?.material; - console.log( - 'UploadFormComponent: Emitting submitRequest', - this.form.value, - ); - this.submitRequest.emit({ - ...this.form.getRawValue(), - items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite - mode: this.mode(), - }); - } else { - console.warn('UploadFormComponent: Form Invalid or No Items'); - console.log('Form Errors:', this.form.errors); - Object.keys(this.form.controls).forEach((key) => { - const control = this.form.get(key); - if (control?.invalid) { - console.log( - 'Invalid Control:', - key, - control.errors, - 'Value:', - control.value, - ); - } - }); + if (!this.form.valid || this.items().length === 0) { this.form.markAllAsTouched(); this.form.get('itemsTouched')?.setValue(true); + return; } + + this.submitRequest.emit(this.getCurrentRequestDraft()); + } + + private setDefaults() { + if (this.materials().length > 0 && !this.form.get('material')?.value) { + const exactPla = this.materials().find( + (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA', + ); + const fallback = exactPla || this.materials()[0]; + this.form.get('material')?.setValue(fallback.value, { emitEvent: false }); + } + + if (this.qualities().length > 0 && !this.form.get('quality')?.value) { + const standard = this.qualities().find((q) => q.value === 'standard'); + this.form + .get('quality') + ?.setValue(standard ? standard.value : this.qualities()[0].value, { + emitEvent: false, + }); + } + + if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) { + this.form.get('nozzleDiameter')?.setValue(0.4, { emitEvent: false }); + } + + if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) { + this.form + .get('infillPattern') + ?.setValue(this.infillPatterns()[0].value, { emitEvent: false }); + } + + this.updateVariants(String(this.form.get('material')?.value || '')); + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + + if (this.mode() === 'easy') { + this.applyEasyPresetFromQuality(String(this.form.get('quality')?.value || 'standard')); + } + + this.emitPrintSettingsChange(); + } + + private applyEasyPresetFromQuality(qualityRaw: string) { + const preset = this.easyModePresetForQuality(qualityRaw); + + this.isPatchingSettings = true; + this.form.patchValue( + { + quality: preset.quality, + nozzleDiameter: preset.nozzleDiameter, + layerHeight: preset.layerHeight, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + + this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); + } + + private easyModePresetForQuality(qualityRaw: string): { + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + } { + const quality = this.normalizeQualityValue(qualityRaw); + + if (quality === 'draft') { + return { + quality: 'draft', + nozzleDiameter: 0.4, + layerHeight: 0.28, + infillDensity: 15, + infillPattern: 'grid', + }; + } + + if (quality === 'extra_fine') { + return { + quality: 'extra_fine', + nozzleDiameter: 0.4, + layerHeight: 0.12, + infillDensity: 20, + infillPattern: 'gyroid', + }; + } + + return { + quality: 'standard', + nozzleDiameter: 0.4, + layerHeight: 0.2, + infillDensity: 15, + infillPattern: 'grid', + }; + } + + private getCurrentGlobalItemDefaults(): { + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const material = String(this.form.get('material')?.value || 'PLA'); + const quality = this.normalizeQualityValue(this.form.get('quality')?.value); + + if (this.mode() === 'easy') { + const preset = this.easyModePresetForQuality(quality); + return { + material, + quality: preset.quality, + nozzleDiameter: preset.nozzleDiameter, + layerHeight: preset.layerHeight, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + supportEnabled: Boolean(this.form.get('supportEnabled')?.value), + }; + } + + return { + material, + quality, + nozzleDiameter: this.normalizeNumber(this.form.get('nozzleDiameter')?.value, 0.4), + layerHeight: this.normalizeNumber(this.form.get('layerHeight')?.value, 0.2), + infillDensity: this.normalizeNumber(this.form.get('infillDensity')?.value, 20), + infillPattern: String(this.form.get('infillPattern')?.value || 'grid'), + supportEnabled: Boolean(this.form.get('supportEnabled')?.value), + }; + } + + private toRequestItem( + item: FormItem, + defaults: ReturnType, + ): QuoteRequestItem { + const quality = this.normalizeQualityValue(item.quality || defaults.quality); + + if (this.mode() === 'easy') { + const preset = this.easyModePresetForQuality(quality); + return { + file: item.file, + quantity: this.normalizeQuantity(item.quantity), + material: item.material || defaults.material, + quality: preset.quality, + color: item.color, + filamentVariantId: item.filamentVariantId, + supportEnabled: item.supportEnabled ?? defaults.supportEnabled, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + layerHeight: preset.layerHeight, + nozzleDiameter: preset.nozzleDiameter, + }; + } + + return { + file: item.file, + quantity: this.normalizeQuantity(item.quantity), + material: item.material || defaults.material, + quality, + color: item.color, + filamentVariantId: item.filamentVariantId, + supportEnabled: item.supportEnabled, + infillDensity: this.normalizeNumber(item.infillDensity, defaults.infillDensity), + infillPattern: item.infillPattern || defaults.infillPattern, + layerHeight: this.normalizeNumber(item.layerHeight, defaults.layerHeight), + nozzleDiameter: this.normalizeNumber(item.nozzleDiameter, defaults.nozzleDiameter), + }; + } + + private applyGlobalSettingsToAllItems() { + const defaults = this.getCurrentGlobalItemDefaults(); + const variants = this.getVariantsForMaterial(defaults.material); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + + this.items.update((current) => + current.map((item) => { + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor || fallback; + + return { + ...item, + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant + ? selectedVariant.id + : item.filamentVariantId, + }; + }), + ); + } + + private syncSelectedItemSettingsFromForm() { + if (this.sameSettingsForAll()) { + return; + } + + const index = this.getSelectedItemIndex(); + if (index < 0) { + return; + } + + const defaults = this.getCurrentGlobalItemDefaults(); + + this.items.update((current) => { + if (index >= current.length) return current; + + return current.map((item, idx) => { + if (idx !== index) { + return item; + } + + const variants = this.getVariantsForMaterial(defaults.material); + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const selectedVariant = byId || byColor || fallback; + + return { + ...item, + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant + ? selectedVariant.id + : item.filamentVariantId, + }; + }); + }); + } + + private loadSelectedItemSettingsIntoForm() { + if (this.sameSettingsForAll()) { + return; + } + + const selected = this.getSelectedItem(); + if (!selected) { + return; + } + + this.isPatchingSettings = true; + this.form.patchValue( + { + material: selected.material, + quality: this.normalizeQualityValue(selected.quality), + nozzleDiameter: selected.nozzleDiameter, + layerHeight: selected.layerHeight, + infillDensity: selected.infillDensity, + infillPattern: selected.infillPattern, + supportEnabled: selected.supportEnabled, + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + + this.updateVariants(selected.material); + this.updateLayerHeightOptionsForNozzle(selected.nozzleDiameter, true); + } + + private updateVariants(materialCode: string) { + const variants = this.getVariantsForMaterial(materialCode); + this.currentMaterialVariants.set(variants); + + if (this.sameSettingsForAll() || !this.selectedFile()) { + return; + } + + if (variants.length === 0) { + return; + } + + const selectedIndex = this.getSelectedItemIndex(); + if (selectedIndex < 0) { + return; + } + + this.items.update((current) => { + if (selectedIndex >= current.length) { + return current; + } + + const selectedItem = current[selectedIndex]; + const byId = + selectedItem.filamentVariantId != null + ? variants.find((v) => v.id === selectedItem.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === selectedItem.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const selectedVariant = byId || byColor || fallback; + + if (!selectedVariant) { + return current; + } + + return current.map((item, idx) => + idx === selectedIndex + ? { + ...item, + color: selectedVariant.colorName, + filamentVariantId: selectedVariant.id, + } + : item, + ); + }); + } + + private updateLayerHeightOptionsForNozzle( + nozzleRaw: unknown, + clampCurrentLayer: boolean, + ) { + const options = this.getLayerHeightOptionsForNozzle(nozzleRaw); + this.layerHeights.set(options); + + if (!clampCurrentLayer || options.length === 0) { + return; + } + + const currentLayer = this.normalizeNumber(this.form.get('layerHeight')?.value, options[0].value as number); + const allowed = options.some( + (option) => + Math.abs(this.normalizeNumber(option.value, currentLayer) - currentLayer) < + 0.0001, + ); + + if (allowed) { + return; + } + + this.isPatchingSettings = true; + this.form.patchValue( + { + layerHeight: Number(options[0].value), + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + } + + private emitPrintSettingsChange() { + const defaults = this.getCurrentGlobalItemDefaults(); + this.printSettingsChange.emit({ + mode: this.mode(), + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + }); + } + + private emitItemSettingsDiffChange() { + if (this.sameSettingsForAll()) { + this.itemSettingsDiffChange.emit({}); + return; + } + + const baseline = this.getCurrentGlobalItemDefaults(); + const diffByFileName: Record = {}; + + this.items().forEach((item) => { + const differences: string[] = []; + + if (this.normalizeText(item.material) !== this.normalizeText(baseline.material)) { + differences.push(item.material.toUpperCase()); + } + + if (this.mode() === 'easy') { + if ( + this.normalizeText(item.quality) !== this.normalizeText(baseline.quality) + ) { + differences.push(`quality:${item.quality}`); + } + } else { + if ( + Math.abs( + this.normalizeNumber(item.nozzleDiameter, baseline.nozzleDiameter) - + baseline.nozzleDiameter, + ) > 0.0001 + ) { + differences.push(`nozzle:${item.nozzleDiameter}`); + } + + if ( + Math.abs( + this.normalizeNumber(item.layerHeight, baseline.layerHeight) - + baseline.layerHeight, + ) > 0.0001 + ) { + differences.push(`layer:${item.layerHeight}`); + } + + if ( + Math.abs( + this.normalizeNumber(item.infillDensity, baseline.infillDensity) - + baseline.infillDensity, + ) > 0.0001 + ) { + differences.push(`infill:${item.infillDensity}%`); + } + + if ( + this.normalizeText(item.infillPattern) !== + this.normalizeText(baseline.infillPattern) + ) { + differences.push(`pattern:${item.infillPattern}`); + } + + if (Boolean(item.supportEnabled) !== Boolean(baseline.supportEnabled)) { + differences.push( + `support:${Boolean(item.supportEnabled) ? 'on' : 'off'}`, + ); + } + } + + if (differences.length > 0) { + diffByFileName[item.file.name] = { differences }; + } + }); + + this.itemSettingsDiffChange.emit(diffByFileName); + } + + private getDefaultVariantSelection(materialCode: string): { + colorName: string; + filamentVariantId?: number; + } { + const variants = this.getVariantsForMaterial(materialCode); + if (variants.length === 0) { + return { colorName: 'Black' }; + } + + const preferred = variants.find((v) => !v.isOutOfStock) || variants[0]; + return { + colorName: preferred.colorName, + filamentVariantId: preferred.id, + }; + } + + private refreshSameSettingsFlag() { + const current = this.items(); + if (current.length <= 1) { + return; + } + + const first = current[0]; + const allEqual = current.every((item) => + this.sameItemSettings(first, item), + ); + + if (!allEqual) { + this.sameSettingsForAll.set(false); + this.form.get('syncAllItems')?.setValue(false, { emitEvent: false }); + } + } + + private sameItemSettings(a: FormItem, b: FormItem): boolean { + return ( + this.normalizeText(a.material) === this.normalizeText(b.material) && + this.normalizeText(a.quality) === this.normalizeText(b.quality) && + Math.abs(this.normalizeNumber(a.nozzleDiameter, 0.4) - this.normalizeNumber(b.nozzleDiameter, 0.4)) < + 0.0001 && + Math.abs(this.normalizeNumber(a.layerHeight, 0.2) - this.normalizeNumber(b.layerHeight, 0.2)) < + 0.0001 && + Math.abs(this.normalizeNumber(a.infillDensity, 20) - this.normalizeNumber(b.infillDensity, 20)) < + 0.0001 && + this.normalizeText(a.infillPattern) === this.normalizeText(b.infillPattern) && + Boolean(a.supportEnabled) === Boolean(b.supportEnabled) + ); + } + + private normalizeQualityValue(value: any): string { + const normalized = String(value || 'standard').trim().toLowerCase(); + if (normalized === 'high' || normalized === 'high_definition') { + return 'extra_fine'; + } + return normalized || 'standard'; } private normalizeQuantity(quantity: number): number { @@ -1004,10 +1199,29 @@ export class UploadFormComponent implements OnInit { return Math.floor(quantity); } + private normalizeNumber(value: any, fallback: number): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; + } + + private normalizeText(value: any): string { + return String(value || '') + .trim() + .toLowerCase(); + } + private normalizeFileName(fileName: string): string { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } + private toNozzleKey(value: unknown): string { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '0.40'; + } + return numeric.toFixed(2); + } + private applySettingsLock(locked: boolean): void { const controlsToLock = [ 'syncAllItems', @@ -1023,6 +1237,7 @@ export class UploadFormComponent implements OnInit { controlsToLock.forEach((name) => { const control = this.form.get(name); if (!control) return; + if (locked) { control.disable({ emitEvent: false }); } else { From aa6322e9283a04b49727cc22bb48931361b3714f Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Thu, 5 Mar 2026 16:28:39 +0000 Subject: [PATCH 08/10] style: apply prettier formatting --- .../pages/admin-dashboard.component.html | 4 +- .../admin/pages/admin-sessions.component.html | 4 +- .../calculator/calculator-page.component.ts | 25 ++-- .../upload-form/upload-form.component.html | 7 +- .../upload-form/upload-form.component.ts | 123 +++++++++++++----- .../services/quote-estimator.service.ts | 54 +++++--- .../features/checkout/checkout.component.html | 5 +- 7 files changed, 160 insertions(+), 62 deletions(-) diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index b3f156b..521def9 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -284,8 +284,8 @@ {{ item.originalFilename }} {{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm - | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% - | {{ item.infillPattern || "-" }} | + | {{ item.layerHeightMm ?? "-" }} mm | + {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} | {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html index 48ee86d..aea368c 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -149,7 +149,9 @@ {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} | - {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} + {{ + item.supportsEnabled ? "Supporti ON" : "Supporti OFF" + }} {{ item.status }} {{ item.unitPriceChf | currency: "CHF" }} diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 8efa766..6737d44 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -72,7 +72,10 @@ export class CalculatorPageComponent implements OnInit { Record >({}); private baselinePrintSettings: TrackedPrintSettings | null = null; - private baselineItemSettingsByFileName = new Map(); + private baselineItemSettingsByFileName = new Map< + string, + TrackedPrintSettings + >(); @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; @@ -528,7 +531,9 @@ export class CalculatorPageComponent implements OnInit { }); } - private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings { + private toTrackedSettingsFromRequest( + req: QuoteRequest, + ): TrackedPrintSettings { return { mode: req.mode, material: this.normalizeString(req.material || 'PLA'), @@ -590,17 +595,17 @@ export class CalculatorPageComponent implements OnInit { item: any, fallback: TrackedPrintSettings, ): TrackedPrintSettings { - const layer = this.normalizeNumber(item?.layerHeightMm, fallback.layerHeight, 3); + 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'), + (layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'), ), nozzleDiameter: this.normalizeNumber( item?.nozzleDiameterMm, @@ -616,9 +621,7 @@ export class CalculatorPageComponent implements OnInit { infillPattern: this.normalizeString( item?.infillPattern || fallback.infillPattern, ), - supportEnabled: Boolean( - item?.supportsEnabled ?? fallback.supportEnabled, - ), + supportEnabled: Boolean(item?.supportsEnabled ?? fallback.supportEnabled), }; } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 843fb2e..25ab039 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -226,7 +226,12 @@
-
+
From 235fe7780dd8404856ad9e5f0bfc5cc0c76b1fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Mar 2026 18:30:37 +0100 Subject: [PATCH 09/10] feat(back-end and front-end): calculator improvements --- .../CustomQuoteRequestController.java | 499 +----------------- .../controller/OptionsController.java | 151 +++++- .../admin/AdminFilamentController.java | 311 +---------- .../admin/AdminOperationsController.java | 446 +--------------- .../com/printcalculator/dto/OrderItemDto.java | 16 + .../repository/NozzleOptionRepository.java | 6 +- .../printcalculator/service/OrderService.java | 2 +- .../service/ProfileManager.java | 185 +++++++ .../service/QuoteSessionTotalsService.java | 47 +- .../admin/AdminFilamentControllerService.java | 327 ++++++++++++ .../AdminOperationsControllerService.java | 469 ++++++++++++++++ .../order/AdminOrderControllerService.java | 12 + .../service/order/OrderControllerService.java | 6 + .../quote/QuoteSessionItemService.java | 37 +- .../quote/QuoteSessionResponseAssembler.java | 3 + .../ContactRequestLocalizationService.java | 216 ++++++++ .../CustomQuoteRequestAttachmentService.java | 155 ++++++ .../CustomQuoteRequestControllerService.java | 68 +++ ...CustomQuoteRequestNotificationService.java | 122 +++++ .../QuoteSessionTotalsServiceTest.java | 51 +- .../AdminFilamentControllerServiceTest.java | 174 ++++++ .../AdminOperationsControllerServiceTest.java | 211 ++++++++ .../AdminOrderControllerServiceTest.java | 228 ++++++++ .../order/OrderControllerServiceTest.java | 183 +++++++ ...ContactRequestLocalizationServiceTest.java | 74 +++ ...stomQuoteRequestAttachmentServiceTest.java | 163 ++++++ ...stomQuoteRequestControllerServiceTest.java | 110 ++++ ...omQuoteRequestNotificationServiceTest.java | 122 +++++ .../pages/admin-dashboard.component.html | 20 +- .../admin/pages/admin-dashboard.component.ts | 63 +++ .../admin/services/admin-orders.service.ts | 4 + .../quote-result/quote-result.component.html | 37 +- .../quote-result/quote-result.component.scss | 45 +- .../quote-result.component.spec.ts | 7 +- .../quote-result/quote-result.component.ts | 30 +- .../upload-form/upload-form.component.html | 22 +- .../upload-form/upload-form.component.scss | 42 ++ .../services/quote-estimator.service.ts | 12 +- .../features/checkout/checkout.component.html | 21 +- .../features/checkout/checkout.component.scss | 12 + .../features/checkout/checkout.component.ts | 74 ++- 41 files changed, 3503 insertions(+), 1280 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java create mode 100644 backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java create mode 100644 backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java create mode 100644 backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 908ef71..47d7d40 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -2,519 +2,46 @@ package com.printcalculator.controller; import com.printcalculator.dto.QuoteRequestDto; import com.printcalculator.entity.CustomQuoteRequest; -import com.printcalculator.entity.CustomQuoteRequestAttachment; -import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; -import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.service.storage.ClamAVService; -import com.printcalculator.service.email.EmailNotificationService; +import com.printcalculator.service.request.CustomQuoteRequestControllerService; import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; -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.Year; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.regex.Pattern; @RestController @RequestMapping("/api/custom-quote-requests") public class CustomQuoteRequestController { - private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class); - private final CustomQuoteRequestRepository requestRepo; - private final CustomQuoteRequestAttachmentRepository attachmentRepo; - private final ClamAVService clamAVService; - private final EmailNotificationService emailNotificationService; + private final CustomQuoteRequestControllerService customQuoteRequestControllerService; - @Value("${app.mail.contact-request.admin.enabled:true}") - private boolean contactRequestAdminMailEnabled; - - @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") - private String contactRequestAdminMailAddress; - - @Value("${app.mail.contact-request.customer.enabled:true}") - private boolean contactRequestCustomerMailEnabled; - - // TODO: Inject Storage Service - private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); - private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); - private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( - "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" - ); - private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( - "application/zip", - "application/x-zip-compressed", - "application/x-rar-compressed", - "application/vnd.rar", - "application/x-7z-compressed", - "application/gzip", - "application/x-gzip", - "application/x-tar", - "application/x-bzip2", - "application/x-xz", - "application/zstd", - "application/x-zstd" - ); - - public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, - CustomQuoteRequestAttachmentRepository attachmentRepo, - ClamAVService clamAVService, - EmailNotificationService emailNotificationService) { - this.requestRepo = requestRepo; - this.attachmentRepo = attachmentRepo; - this.clamAVService = clamAVService; - this.emailNotificationService = emailNotificationService; + public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) { + this.customQuoteRequestControllerService = customQuoteRequestControllerService; } - // 1. Create Custom Quote Request @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity createCustomQuoteRequest( @Valid @RequestPart("request") QuoteRequestDto requestDto, @RequestPart(value = "files", required = false) List files ) throws IOException { - if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Accettazione Termini e Privacy obbligatoria." - ); - } - String language = normalizeLanguage(requestDto.getLanguage()); - - // 1. Create Request - CustomQuoteRequest request = new CustomQuoteRequest(); - request.setRequestType(requestDto.getRequestType()); - request.setCustomerType(requestDto.getCustomerType()); - request.setEmail(requestDto.getEmail()); - request.setPhone(requestDto.getPhone()); - request.setName(requestDto.getName()); - request.setCompanyName(requestDto.getCompanyName()); - request.setContactPerson(requestDto.getContactPerson()); - request.setMessage(requestDto.getMessage()); - request.setStatus("PENDING"); - request.setCreatedAt(OffsetDateTime.now()); - request.setUpdatedAt(OffsetDateTime.now()); - - request = requestRepo.save(request); - - // 2. Handle Attachments - int attachmentsCount = 0; - if (files != null && !files.isEmpty()) { - if (files.size() > 15) { - throw new IOException("Too many files. Max 15 allowed."); - } - - for (MultipartFile file : files) { - if (file.isEmpty()) continue; - - if (isCompressedFile(file)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Compressed files are not allowed." - ); - } - - // Scan for virus - clamAVService.scan(file.getInputStream()); - - CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); - attachment.setRequest(request); - attachment.setOriginalFilename(file.getOriginalFilename()); - attachment.setMimeType(file.getContentType()); - attachment.setFileSizeBytes(file.getSize()); - attachment.setCreatedAt(OffsetDateTime.now()); - - // Generate path - UUID fileUuid = UUID.randomUUID(); - String storedFilename = fileUuid + ".upload"; - - // Note: We don't have attachment ID yet. - // We'll save attachment first to get ID. - attachment.setStoredFilename(storedFilename); - attachment.setStoredRelativePath("PENDING"); - - attachment = attachmentRepo.save(attachment); - - Path relativePath = Path.of( - "quote-requests", - request.getId().toString(), - "attachments", - attachment.getId().toString(), - storedFilename - ); - attachment.setStoredRelativePath(relativePath.toString()); - attachmentRepo.save(attachment); - - // Save file to disk - Path absolutePath = resolveWithinStorageRoot(relativePath); - Files.createDirectories(absolutePath.getParent()); - try (InputStream inputStream = file.getInputStream()) { - Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); - } - attachmentsCount++; - } - } - - sendAdminContactRequestNotification(request, attachmentsCount); - sendCustomerContactRequestConfirmation(request, attachmentsCount, language); - - return ResponseEntity.ok(request); + return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files)); } - - // 2. Get Request + @GetMapping("/{id}") public ResponseEntity getCustomQuoteRequest(@PathVariable UUID id) { - return requestRepo.findById(id) + return customQuoteRequestControllerService.getCustomQuoteRequest(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } - - // Helper - private String getExtension(String filename) { - if (filename == null) return "dat"; - String cleaned = StringUtils.cleanPath(filename); - if (cleaned.contains("..")) { - return "dat"; - } - int i = cleaned.lastIndexOf('.'); - if (i > 0 && i < cleaned.length() - 1) { - String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); - if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { - return ext; - } - } - return "dat"; - } - - private boolean isCompressedFile(MultipartFile file) { - String ext = getExtension(file.getOriginalFilename()); - if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { - return true; - } - String mime = file.getContentType(); - return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase()); - } - - private Path resolveWithinStorageRoot(Path relativePath) { - try { - Path normalizedRelative = relativePath.normalize(); - if (normalizedRelative.isAbsolute()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); - if (!absolutePath.startsWith(STORAGE_ROOT)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - return absolutePath; - } catch (InvalidPathException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); - } - } - - private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) { - if (!contactRequestAdminMailEnabled) { - return; - } - if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) { - logger.warn("Contact request admin notification enabled but no admin address configured."); - return; - } - - Map templateData = new HashMap<>(); - templateData.put("requestId", request.getId()); - templateData.put("createdAt", request.getCreatedAt()); - templateData.put("requestType", safeValue(request.getRequestType())); - templateData.put("customerType", safeValue(request.getCustomerType())); - templateData.put("name", safeValue(request.getName())); - templateData.put("companyName", safeValue(request.getCompanyName())); - templateData.put("contactPerson", safeValue(request.getContactPerson())); - templateData.put("email", safeValue(request.getEmail())); - templateData.put("phone", safeValue(request.getPhone())); - templateData.put("message", safeValue(request.getMessage())); - templateData.put("attachmentsCount", attachmentsCount); - templateData.put("currentYear", Year.now().getValue()); - - emailNotificationService.sendEmail( - contactRequestAdminMailAddress, - "Nuova richiesta di contatto #" + request.getId(), - "contact-request-admin", - templateData - ); - } - - private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) { - if (!contactRequestCustomerMailEnabled) { - return; - } - if (request.getEmail() == null || request.getEmail().isBlank()) { - logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId()); - return; - } - - Map templateData = new HashMap<>(); - templateData.put("requestId", request.getId()); - templateData.put( - "createdAt", - request.getCreatedAt().format( - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language)) - ) - ); - templateData.put("recipientName", resolveRecipientName(request, language)); - templateData.put("requestType", localizeRequestType(request.getRequestType(), language)); - templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language)); - templateData.put("name", safeValue(request.getName())); - templateData.put("companyName", safeValue(request.getCompanyName())); - templateData.put("contactPerson", safeValue(request.getContactPerson())); - templateData.put("email", safeValue(request.getEmail())); - templateData.put("phone", safeValue(request.getPhone())); - templateData.put("message", safeValue(request.getMessage())); - templateData.put("attachmentsCount", attachmentsCount); - templateData.put("currentYear", Year.now().getValue()); - String subject = applyCustomerContactRequestTexts(templateData, language, request.getId()); - - emailNotificationService.sendEmail( - request.getEmail(), - subject, - "contact-request-customer", - templateData - ); - } - - private String applyCustomerContactRequestTexts( - Map templateData, - String language, - UUID requestId - ) { - return switch (language) { - case "en" -> { - templateData.put("emailTitle", "Contact request received"); - templateData.put("headlineText", "We received your contact request"); - templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ","); - templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible."); - templateData.put("requestIdHintText", "Please keep this request ID for future order references:"); - templateData.put("detailsTitleText", "Request details"); - templateData.put("labelRequestId", "Request ID"); - templateData.put("labelDate", "Date"); - templateData.put("labelRequestType", "Request type"); - templateData.put("labelCustomerType", "Customer type"); - templateData.put("labelName", "Name"); - templateData.put("labelCompany", "Company"); - templateData.put("labelContactPerson", "Contact person"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Phone"); - templateData.put("labelMessage", "Message"); - templateData.put("labelAttachments", "Attachments"); - templateData.put("supportText", "If you need help, reply to this email."); - templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab."); - yield "We received your contact request #" + requestId + " - 3D-Fab"; - } - case "de" -> { - templateData.put("emailTitle", "Kontaktanfrage erhalten"); - templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten"); - templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ","); - templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich."); - templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:"); - templateData.put("detailsTitleText", "Anfragedetails"); - templateData.put("labelRequestId", "Anfrage-ID"); - templateData.put("labelDate", "Datum"); - templateData.put("labelRequestType", "Anfragetyp"); - templateData.put("labelCustomerType", "Kundentyp"); - templateData.put("labelName", "Name"); - templateData.put("labelCompany", "Firma"); - templateData.put("labelContactPerson", "Kontaktperson"); - templateData.put("labelEmail", "E-Mail"); - templateData.put("labelPhone", "Telefon"); - templateData.put("labelMessage", "Nachricht"); - templateData.put("labelAttachments", "Anhaenge"); - templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail."); - templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab."); - yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab"; - } - case "fr" -> { - templateData.put("emailTitle", "Demande de contact recue"); - templateData.put("headlineText", "Nous avons recu votre demande de contact"); - templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ","); - templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible."); - templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :"); - templateData.put("detailsTitleText", "Details de la demande"); - templateData.put("labelRequestId", "ID de demande"); - templateData.put("labelDate", "Date"); - templateData.put("labelRequestType", "Type de demande"); - templateData.put("labelCustomerType", "Type de client"); - templateData.put("labelName", "Nom"); - templateData.put("labelCompany", "Entreprise"); - templateData.put("labelContactPerson", "Contact"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Telephone"); - templateData.put("labelMessage", "Message"); - templateData.put("labelAttachments", "Pieces jointes"); - templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); - templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab."); - yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab"; - } - default -> { - templateData.put("emailTitle", "Richiesta di contatto ricevuta"); - templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto"); - templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ","); - templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile."); - templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:"); - templateData.put("detailsTitleText", "Dettagli richiesta"); - templateData.put("labelRequestId", "ID richiesta"); - templateData.put("labelDate", "Data"); - templateData.put("labelRequestType", "Tipo richiesta"); - templateData.put("labelCustomerType", "Tipo cliente"); - templateData.put("labelName", "Nome"); - templateData.put("labelCompany", "Azienda"); - templateData.put("labelContactPerson", "Contatto"); - templateData.put("labelEmail", "Email"); - templateData.put("labelPhone", "Telefono"); - templateData.put("labelMessage", "Messaggio"); - templateData.put("labelAttachments", "Allegati"); - templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email."); - templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab."); - yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab"; - } - }; - } - - private String localizeRequestType(String requestType, String language) { - if (requestType == null || requestType.isBlank()) { - return "-"; - } - - String normalized = requestType.trim().toLowerCase(Locale.ROOT); - return switch (language) { - case "en" -> switch (normalized) { - case "custom", "print_service" -> "Custom part request"; - case "series" -> "Series production request"; - case "consult", "design_service" -> "Consultation request"; - case "question" -> "General question"; - default -> requestType; - }; - case "de" -> switch (normalized) { - case "custom", "print_service" -> "Anfrage fuer Einzelteil"; - case "series" -> "Anfrage fuer Serienproduktion"; - case "consult", "design_service" -> "Beratungsanfrage"; - case "question" -> "Allgemeine Frage"; - default -> requestType; - }; - case "fr" -> switch (normalized) { - case "custom", "print_service" -> "Demande de piece personnalisee"; - case "series" -> "Demande de production en serie"; - case "consult", "design_service" -> "Demande de conseil"; - case "question" -> "Question generale"; - default -> requestType; - }; - default -> switch (normalized) { - case "custom", "print_service" -> "Richiesta pezzo personalizzato"; - case "series" -> "Richiesta produzione in serie"; - case "consult", "design_service" -> "Richiesta consulenza"; - case "question" -> "Domanda generale"; - default -> requestType; - }; - }; - } - - private String localizeCustomerType(String customerType, String language) { - if (customerType == null || customerType.isBlank()) { - return "-"; - } - String normalized = customerType.trim().toLowerCase(Locale.ROOT); - return switch (language) { - case "en" -> switch (normalized) { - case "private" -> "Private"; - case "business" -> "Business"; - default -> customerType; - }; - case "de" -> switch (normalized) { - case "private" -> "Privat"; - case "business" -> "Unternehmen"; - default -> customerType; - }; - case "fr" -> switch (normalized) { - case "private" -> "Prive"; - case "business" -> "Entreprise"; - default -> customerType; - }; - default -> switch (normalized) { - case "private" -> "Privato"; - case "business" -> "Azienda"; - default -> customerType; - }; - }; - } - - private Locale localeForLanguage(String language) { - return switch (language) { - case "en" -> Locale.ENGLISH; - case "de" -> Locale.GERMAN; - case "fr" -> Locale.FRENCH; - default -> Locale.ITALIAN; - }; - } - - private String normalizeLanguage(String language) { - if (language == null || language.isBlank()) { - return "it"; - } - String normalized = language.toLowerCase(Locale.ROOT).trim(); - if (normalized.startsWith("en")) { - return "en"; - } - if (normalized.startsWith("de")) { - return "de"; - } - if (normalized.startsWith("fr")) { - return "fr"; - } - return "it"; - } - - private String resolveRecipientName(CustomQuoteRequest request, String language) { - if (request.getName() != null && !request.getName().isBlank()) { - return request.getName().trim(); - } - if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { - return request.getContactPerson().trim(); - } - if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) { - return request.getCompanyName().trim(); - } - return switch (language) { - case "en" -> "customer"; - case "de" -> "Kunde"; - case "fr" -> "client"; - default -> "cliente"; - }; - } - - private String safeValue(String value) { - if (value == null || value.isBlank()) { - return "-"; - } - return value; - } } diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 56cd26b..28a1abb 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -12,8 +12,10 @@ import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.repository.PrinterMachineProfileRepository; import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.ProfileManager; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; @@ -22,8 +24,11 @@ import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.Comparator; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.LinkedHashSet; import java.util.Set; import java.util.stream.Collectors; @@ -34,23 +39,29 @@ public class OptionsController { private final FilamentVariantRepository variantRepo; private final NozzleOptionRepository nozzleRepo; private final PrinterMachineRepository printerMachineRepo; + private final PrinterMachineProfileRepository printerMachineProfileRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final OrcaProfileResolver orcaProfileResolver; + private final ProfileManager profileManager; private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, NozzleOptionRepository nozzleRepo, PrinterMachineRepository printerMachineRepo, + PrinterMachineProfileRepository printerMachineProfileRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo, OrcaProfileResolver orcaProfileResolver, + ProfileManager profileManager, NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.nozzleRepo = nozzleRepo; this.printerMachineRepo = printerMachineRepo; + this.printerMachineProfileRepo = printerMachineProfileRepo; this.materialOrcaMapRepo = materialOrcaMapRepo; this.orcaProfileResolver = orcaProfileResolver; + this.profileManager = profileManager; this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @@ -116,8 +127,27 @@ public class OptionsController { new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); + PrinterMachine targetMachine = resolveMachine(printerMachineId); + + Set supportedMachineNozzles = targetMachine != null + ? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream() + .map(PrinterMachineProfile::getNozzleDiameterMm) + .filter(v -> v != null) + .map(nozzleLayerHeightPolicyService::normalizeNozzle) + .collect(Collectors.toCollection(LinkedHashSet::new)) + : Set.of(); + + boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty(); + List nozzles = nozzleRepo.findAll().stream() .filter(n -> Boolean.TRUE.equals(n.getIsActive())) + .filter(n -> { + if (!restrictNozzlesByMachineProfile) { + return true; + } + BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(n.getNozzleDiameterMm()); + return normalized != null && supportedMachineNozzles.contains(normalized); + }) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .map(n -> new OptionsResponse.NozzleOptionDTO( n.getNozzleDiameterMm().doubleValue(), @@ -129,16 +159,62 @@ public class OptionsController { .toList(); Map> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); + Set visibleNozzlesFromOptions = nozzles.stream() + .map(OptionsResponse.NozzleOptionDTO::value) + .map(BigDecimal::valueOf) + .map(nozzleLayerHeightPolicyService::normalizeNozzle) + .filter(v -> v != null) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + Map> effectiveRulesByNozzle = new LinkedHashMap<>(); + for (BigDecimal nozzle : visibleNozzlesFromOptions) { + List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of()); + List compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle); + List effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers); + if (!effective.isEmpty()) { + effectiveRulesByNozzle.put(nozzle, effective); + } + } + if (effectiveRulesByNozzle.isEmpty()) { + for (BigDecimal nozzle : visibleNozzlesFromOptions) { + List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of()); + if (!policyLayers.isEmpty()) { + effectiveRulesByNozzle.put(nozzle, policyLayers); + } + } + } + + Set visibleNozzles = new LinkedHashSet<>(effectiveRulesByNozzle.keySet()); + nozzles = nozzles.stream() + .filter(option -> { + BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle( + BigDecimal.valueOf(option.value()) + ); + return normalized != null && visibleNozzles.contains(normalized); + }) + .toList(); + BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null ); - - List layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of())); - if (layers.isEmpty()) { - layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) { + selectedNozzle = visibleNozzles.iterator().next(); } - List layerHeightsByNozzle = rulesByNozzle.entrySet().stream() + List layers = toLayerDtos( + effectiveRulesByNozzle.getOrDefault(selectedNozzle, List.of()) + ); + if (layers.isEmpty()) { + if (!visibleNozzles.isEmpty()) { + BigDecimal fallbackNozzle = visibleNozzles.iterator().next(); + layers = toLayerDtos(effectiveRulesByNozzle.getOrDefault(fallbackNozzle, List.of())); + } + if (layers.isEmpty()) { + layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + } + } + + List layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream() .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO( entry.getKey().doubleValue(), toLayerDtos(entry.getValue()) @@ -156,13 +232,7 @@ public class OptionsController { } private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { - PrinterMachine machine = null; - if (printerMachineId != null) { - machine = printerMachineRepo.findById(printerMachineId).orElse(null); - } - if (machine == null) { - machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); - } + PrinterMachine machine = resolveMachine(printerMachineId); if (machine == null) { return Set.of(); } @@ -187,6 +257,17 @@ public class OptionsController { .collect(Collectors.toSet()); } + private PrinterMachine resolveMachine(Long printerMachineId) { + PrinterMachine machine = null; + if (printerMachineId != null) { + machine = printerMachineRepo.findById(printerMachineId).orElse(null); + } + if (machine == null) { + machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); + } + return machine; + } + private List toLayerDtos(List layers) { return layers.stream() .sorted(Comparator.naturalOrder()) @@ -197,6 +278,52 @@ public class OptionsController { .toList(); } + private List resolveCompatibleProcessLayers(PrinterMachine machine, BigDecimal nozzle) { + if (machine == null || nozzle == null) { + return List.of(); + } + PrinterMachineProfile profile = orcaProfileResolver.resolveMachineProfile(machine, nozzle).orElse(null); + if (profile == null || profile.getOrcaMachineProfileName() == null) { + return List.of(); + } + return profileManager.findCompatibleProcessLayers(profile.getOrcaMachineProfileName()); + } + + private List mergePolicyAndProcessLayers(List policyLayers, + List processLayers) { + if ((processLayers == null || processLayers.isEmpty()) + && (policyLayers == null || policyLayers.isEmpty())) { + return List.of(); + } + + if (processLayers == null || processLayers.isEmpty()) { + return policyLayers != null ? policyLayers : List.of(); + } + + if (policyLayers == null || policyLayers.isEmpty()) { + return processLayers; + } + + Set allowedByPolicy = policyLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + List intersection = processLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null && allowedByPolicy.contains(v)) + .collect(Collectors.toCollection(ArrayList::new)); + + if (!intersection.isEmpty()) { + return intersection; + } + + return processLayers.stream() + .map(nozzleLayerHeightPolicyService::normalizeLayer) + .filter(v -> v != null) + .collect(Collectors.toCollection(ArrayList::new)); + } + private String resolveHexColor(FilamentVariant variant) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { return variant.getColorHex(); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java index 2d469e6..b6da0b8 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java @@ -4,77 +4,39 @@ import com.printcalculator.dto.AdminFilamentMaterialTypeDto; import com.printcalculator.dto.AdminFilamentVariantDto; import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; -import com.printcalculator.entity.FilamentMaterialType; -import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.repository.FilamentMaterialTypeRepository; -import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.admin.AdminFilamentControllerService; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.util.Comparator; import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/admin/filaments") @Transactional(readOnly = true) public class AdminFilamentController { - private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); - private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); - private static final Set ALLOWED_FINISH_TYPES = Set.of( - "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" - ); - private final FilamentMaterialTypeRepository materialRepo; - private final FilamentVariantRepository variantRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final OrderItemRepository orderItemRepo; + private final AdminFilamentControllerService adminFilamentControllerService; - public AdminFilamentController( - FilamentMaterialTypeRepository materialRepo, - FilamentVariantRepository variantRepo, - QuoteLineItemRepository quoteLineItemRepo, - OrderItemRepository orderItemRepo - ) { - this.materialRepo = materialRepo; - this.variantRepo = variantRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.orderItemRepo = orderItemRepo; + public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) { + this.adminFilamentControllerService = adminFilamentControllerService; } @GetMapping("/materials") public ResponseEntity> getMaterials() { - List response = materialRepo.findAll().stream() - .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) - .map(this::toMaterialDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminFilamentControllerService.getMaterials()); } @GetMapping("/variants") public ResponseEntity> getVariants() { - List response = variantRepo.findAll().stream() - .sorted(Comparator - .comparing((FilamentVariant v) -> { - FilamentMaterialType type = v.getFilamentMaterialType(); - return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; - }, String.CASE_INSENSITIVE_ORDER) - .thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) - .map(this::toVariantDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminFilamentControllerService.getVariants()); } @PostMapping("/materials") @@ -82,13 +44,7 @@ public class AdminFilamentController { public ResponseEntity createMaterial( @RequestBody AdminUpsertFilamentMaterialTypeRequest payload ) { - String materialCode = normalizeAndValidateMaterialCode(payload); - ensureMaterialCodeAvailable(materialCode, null); - - FilamentMaterialType material = new FilamentMaterialType(); - applyMaterialPayload(material, payload, materialCode); - FilamentMaterialType saved = materialRepo.save(material); - return ResponseEntity.ok(toMaterialDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload)); } @PutMapping("/materials/{materialTypeId}") @@ -97,15 +53,7 @@ public class AdminFilamentController { @PathVariable Long materialTypeId, @RequestBody AdminUpsertFilamentMaterialTypeRequest payload ) { - FilamentMaterialType material = materialRepo.findById(materialTypeId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); - - String materialCode = normalizeAndValidateMaterialCode(payload); - ensureMaterialCodeAvailable(materialCode, materialTypeId); - - applyMaterialPayload(material, payload, materialCode); - FilamentMaterialType saved = materialRepo.save(material); - return ResponseEntity.ok(toMaterialDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload)); } @PostMapping("/variants") @@ -113,17 +61,7 @@ public class AdminFilamentController { public ResponseEntity createVariant( @RequestBody AdminUpsertFilamentVariantRequest payload ) { - FilamentMaterialType material = validateAndResolveMaterial(payload); - String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); - String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); - validateNumericPayload(payload); - ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); - - FilamentVariant variant = new FilamentVariant(); - variant.setCreatedAt(OffsetDateTime.now()); - applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); - FilamentVariant saved = variantRepo.save(variant); - return ResponseEntity.ok(toVariantDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.createVariant(payload)); } @PutMapping("/variants/{variantId}") @@ -132,224 +70,13 @@ public class AdminFilamentController { @PathVariable Long variantId, @RequestBody AdminUpsertFilamentVariantRequest payload ) { - FilamentVariant variant = variantRepo.findById(variantId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); - - FilamentMaterialType material = validateAndResolveMaterial(payload); - String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); - String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); - validateNumericPayload(payload); - ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); - - applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); - FilamentVariant saved = variantRepo.save(variant); - return ResponseEntity.ok(toVariantDto(saved)); + return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload)); } @DeleteMapping("/variants/{variantId}") @Transactional public ResponseEntity deleteVariant(@PathVariable Long variantId) { - FilamentVariant variant = variantRepo.findById(variantId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); - - if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { - throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); - } - - variantRepo.delete(variant); + adminFilamentControllerService.deleteVariant(variantId); return ResponseEntity.noContent().build(); } - - private void applyMaterialPayload( - FilamentMaterialType material, - AdminUpsertFilamentMaterialTypeRequest payload, - String normalizedMaterialCode - ) { - boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); - boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); - String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null - ? payload.getTechnicalTypeLabel().trim() - : null; - - material.setMaterialCode(normalizedMaterialCode); - material.setIsFlexible(isFlexible); - material.setIsTechnical(isTechnical); - material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() - ? technicalTypeLabel - : null); - } - - private void applyVariantPayload( - FilamentVariant variant, - AdminUpsertFilamentVariantRequest payload, - FilamentMaterialType material, - String normalizedDisplayName, - String normalizedColorName - ) { - String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); - String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); - String normalizedBrand = normalizeOptional(payload.getBrand()); - - variant.setFilamentMaterialType(material); - variant.setVariantDisplayName(normalizedDisplayName); - variant.setColorName(normalizedColorName); - variant.setColorHex(normalizedColorHex); - variant.setFinishType(normalizedFinishType); - variant.setBrand(normalizedBrand); - variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); - variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); - variant.setCostChfPerKg(payload.getCostChfPerKg()); - variant.setStockSpools(payload.getStockSpools()); - variant.setSpoolNetKg(payload.getSpoolNetKg()); - variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); - } - - private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { - if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); - } - return payload.getMaterialCode().trim().toUpperCase(); - } - - private String normalizeAndValidateVariantDisplayName(String value) { - if (value == null || value.isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); - } - return value.trim(); - } - - private String normalizeAndValidateColorName(String value) { - if (value == null || value.isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); - } - return value.trim(); - } - - private String normalizeAndValidateColorHex(String value) { - if (value == null || value.isBlank()) { - return null; - } - String normalized = value.trim(); - if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { - throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); - } - return normalized.toUpperCase(Locale.ROOT); - } - - private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { - String normalized = finishType == null || finishType.isBlank() - ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") - : finishType.trim().toUpperCase(Locale.ROOT); - if (!ALLOWED_FINISH_TYPES.contains(normalized)) { - throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); - } - return normalized; - } - - private String normalizeOptional(String value) { - if (value == null) { - return null; - } - String normalized = value.trim(); - return normalized.isBlank() ? null : normalized; - } - - private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { - if (payload == null || payload.getMaterialTypeId() == null) { - throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); - } - - return materialRepo.findById(payload.getMaterialTypeId()) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); - } - - private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { - if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { - throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); - } - validateNumeric63(payload.getStockSpools(), "Stock spools", true); - validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); - } - - private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { - if (value == null) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); - } - - if (allowZero) { - if (value.compareTo(BigDecimal.ZERO) < 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); - } - } else if (value.compareTo(BigDecimal.ZERO) <= 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); - } - - if (value.scale() > 3) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); - } - - if (value.compareTo(MAX_NUMERIC_6_3) > 0) { - throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); - } - } - - private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { - materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { - if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { - throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); - } - }); - } - - private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { - variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { - if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { - throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); - } - }); - } - - private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { - AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); - dto.setId(material.getId()); - dto.setMaterialCode(material.getMaterialCode()); - dto.setIsFlexible(material.getIsFlexible()); - dto.setIsTechnical(material.getIsTechnical()); - dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); - return dto; - } - - private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { - AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); - dto.setId(variant.getId()); - - FilamentMaterialType material = variant.getFilamentMaterialType(); - if (material != null) { - dto.setMaterialTypeId(material.getId()); - dto.setMaterialCode(material.getMaterialCode()); - dto.setMaterialIsFlexible(material.getIsFlexible()); - dto.setMaterialIsTechnical(material.getIsTechnical()); - dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); - } - - dto.setVariantDisplayName(variant.getVariantDisplayName()); - dto.setColorName(variant.getColorName()); - dto.setColorHex(variant.getColorHex()); - dto.setFinishType(variant.getFinishType()); - dto.setBrand(variant.getBrand()); - dto.setIsMatte(variant.getIsMatte()); - dto.setIsSpecial(variant.getIsSpecial()); - dto.setCostChfPerKg(variant.getCostChfPerKg()); - dto.setStockSpools(variant.getStockSpools()); - dto.setSpoolNetKg(variant.getSpoolNetKg()); - BigDecimal stockKg = BigDecimal.ZERO; - if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { - stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); - } - dto.setStockKg(stockKg); - dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); - dto.setIsActive(variant.getIsActive()); - dto.setCreatedAt(variant.getCreatedAt()); - return dto; - } } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java index 52b8149..19c4d0b 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -1,37 +1,14 @@ package com.printcalculator.controller.admin; -import com.printcalculator.dto.AdminContactRequestDto; -import com.printcalculator.dto.AdminContactRequestAttachmentDto; -import com.printcalculator.dto.AdminContactRequestDetailDto; import com.printcalculator.dto.AdminCadInvoiceCreateRequest; import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminContactRequestDto; import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; -import com.printcalculator.entity.CustomQuoteRequest; -import com.printcalculator.entity.CustomQuoteRequestAttachment; -import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.FilamentVariantStockKg; -import com.printcalculator.entity.Order; -import com.printcalculator.entity.QuoteLineItem; -import com.printcalculator.entity.QuoteSession; -import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; -import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.FilamentVariantStockKgRepository; -import com.printcalculator.repository.OrderRepository; -import com.printcalculator.repository.PricingPolicyRepository; -import com.printcalculator.repository.QuoteLineItemRepository; -import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.service.QuoteSessionTotalsService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.printcalculator.service.admin.AdminOperationsControllerService; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; -import org.springframework.data.domain.Sort; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; @@ -42,148 +19,34 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.net.MalformedURLException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.OffsetDateTime; -import java.util.Collections; -import java.util.Comparator; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; -import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/admin") @Transactional(readOnly = true) public class AdminOperationsController { - private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class); - private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); - private static final Set CONTACT_REQUEST_ALLOWED_STATUSES = Set.of( - "NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED" - ); - private final FilamentVariantStockKgRepository filamentStockRepo; - private final FilamentVariantRepository filamentVariantRepo; - private final CustomQuoteRequestRepository customQuoteRequestRepo; - private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; - private final QuoteSessionRepository quoteSessionRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final OrderRepository orderRepo; - private final PricingPolicyRepository pricingRepo; - private final QuoteSessionTotalsService quoteSessionTotalsService; + private final AdminOperationsControllerService adminOperationsControllerService; - public AdminOperationsController( - FilamentVariantStockKgRepository filamentStockRepo, - FilamentVariantRepository filamentVariantRepo, - CustomQuoteRequestRepository customQuoteRequestRepo, - CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, - QuoteSessionRepository quoteSessionRepo, - QuoteLineItemRepository quoteLineItemRepo, - OrderRepository orderRepo, - PricingPolicyRepository pricingRepo, - QuoteSessionTotalsService quoteSessionTotalsService - ) { - this.filamentStockRepo = filamentStockRepo; - this.filamentVariantRepo = filamentVariantRepo; - this.customQuoteRequestRepo = customQuoteRequestRepo; - this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; - this.quoteSessionRepo = quoteSessionRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.orderRepo = orderRepo; - this.pricingRepo = pricingRepo; - this.quoteSessionTotalsService = quoteSessionTotalsService; + public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) { + this.adminOperationsControllerService = adminOperationsControllerService; } @GetMapping("/filament-stock") public ResponseEntity> getFilamentStock() { - List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); - Set variantIds = stocks.stream() - .map(FilamentVariantStockKg::getFilamentVariantId) - .collect(Collectors.toSet()); - - Map variantsById; - if (variantIds.isEmpty()) { - variantsById = Collections.emptyMap(); - } else { - variantsById = filamentVariantRepo.findAllById(variantIds).stream() - .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); - } - - List response = stocks.stream().map(stock -> { - FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); - AdminFilamentStockDto dto = new AdminFilamentStockDto(); - dto.setFilamentVariantId(stock.getFilamentVariantId()); - dto.setStockSpools(stock.getStockSpools()); - dto.setSpoolNetKg(stock.getSpoolNetKg()); - dto.setStockKg(stock.getStockKg()); - BigDecimal grams = stock.getStockKg() != null - ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) - : BigDecimal.ZERO; - dto.setStockFilamentGrams(grams); - - if (variant != null) { - dto.setMaterialCode( - variant.getFilamentMaterialType() != null - ? variant.getFilamentMaterialType().getMaterialCode() - : "UNKNOWN" - ); - dto.setVariantDisplayName(variant.getVariantDisplayName()); - dto.setColorName(variant.getColorName()); - dto.setActive(variant.getIsActive()); - } else { - dto.setMaterialCode("UNKNOWN"); - dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); - dto.setColorName("-"); - dto.setActive(false); - } - - return dto; - }).toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock()); } @GetMapping("/contact-requests") public ResponseEntity> getContactRequests() { - List response = customQuoteRequestRepo.findAll( - Sort.by(Sort.Direction.DESC, "createdAt") - ) - .stream() - .map(this::toContactRequestDto) - .toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getContactRequests()); } @GetMapping("/contact-requests/{requestId}") public ResponseEntity getContactRequestDetail(@PathVariable UUID requestId) { - CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); - - List attachments = customQuoteRequestAttachmentRepo - .findByRequest_IdOrderByCreatedAtAsc(requestId) - .stream() - .map(this::toContactRequestAttachmentDto) - .toList(); - - return ResponseEntity.ok(toContactRequestDetailDto(request, attachments)); + return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId)); } @PatchMapping("/contact-requests/{requestId}/status") @@ -192,31 +55,7 @@ public class AdminOperationsController { @PathVariable UUID requestId, @RequestBody AdminUpdateContactRequestStatusRequest payload ) { - CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); - - String requestedStatus = payload != null && payload.getStatus() != null - ? payload.getStatus().trim().toUpperCase(Locale.ROOT) - : ""; - - if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) { - throw new ResponseStatusException( - BAD_REQUEST, - "Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES) - ); - } - - request.setStatus(requestedStatus); - request.setUpdatedAt(OffsetDateTime.now()); - CustomQuoteRequest saved = customQuoteRequestRepo.save(request); - - List attachments = customQuoteRequestAttachmentRepo - .findByRequest_IdOrderByCreatedAtAsc(requestId) - .stream() - .map(this::toContactRequestAttachmentDto) - .toList(); - - return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments)); + return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload)); } @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") @@ -224,87 +63,17 @@ public class AdminOperationsController { @PathVariable UUID requestId, @PathVariable UUID attachmentId ) { - CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); - - if (!attachment.getRequest().getId().equals(requestId)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); - } - - String relativePath = attachment.getStoredRelativePath(); - if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; - if (!relativePath.startsWith(expectedPrefix)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); - if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - if (!Files.exists(filePath)) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - try { - Resource resource = new UrlResource(filePath.toUri()); - if (!resource.exists() || !resource.isReadable()) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } - - MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; - String mimeType = attachment.getMimeType(); - if (mimeType != null && !mimeType.isBlank()) { - try { - mediaType = MediaType.parseMediaType(mimeType); - } catch (Exception ignored) { - mediaType = MediaType.APPLICATION_OCTET_STREAM; - } - } - - String filename = attachment.getOriginalFilename(); - if (filename == null || filename.isBlank()) { - filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() - ? attachment.getStoredFilename() - : "attachment-" + attachmentId; - } - - return ResponseEntity.ok() - .contentType(mediaType) - .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build() - .toString()) - .body(resource); - } catch (MalformedURLException e) { - throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); - } + return adminOperationsControllerService.downloadContactRequestAttachment(requestId, attachmentId); } @GetMapping("/sessions") public ResponseEntity> getQuoteSessions() { - List response = quoteSessionRepo.findAll( - Sort.by(Sort.Direction.DESC, "createdAt") - ) - .stream() - .map(this::toQuoteSessionDto) - .toList(); - - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions()); } @GetMapping("/cad-invoices") public ResponseEntity> getCadInvoices() { - List response = quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) - .stream() - .filter(this::isCadSessionRecord) - .map(this::toCadInvoiceDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOperationsControllerService.getCadInvoices()); } @PostMapping("/cad-invoices") @@ -312,198 +81,13 @@ public class AdminOperationsController { public ResponseEntity createOrUpdateCadInvoice( @RequestBody AdminCadInvoiceCreateRequest payload ) { - if (payload == null || payload.getCadHours() == null) { - throw new ResponseStatusException(BAD_REQUEST, "cadHours is required"); - } - - BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP); - if (cadHours.compareTo(BigDecimal.ZERO) <= 0) { - throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0"); - } - - BigDecimal cadRate = payload.getCadHourlyRateChf(); - if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) { - var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); - cadRate = policy != null && policy.getCadCostChfPerHour() != null - ? policy.getCadCostChfPerHour() - : BigDecimal.ZERO; - } - cadRate = cadRate.setScale(2, RoundingMode.HALF_UP); - - QuoteSession session; - if (payload.getSessionId() != null) { - session = quoteSessionRepo.findById(payload.getSessionId()) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); - } else { - session = new QuoteSession(); - session.setStatus("CAD_ACTIVE"); - session.setPricingVersion("v1"); - session.setMaterialCode("PLA"); - session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); - session.setLayerHeightMm(BigDecimal.valueOf(0.2)); - session.setInfillPattern("grid"); - session.setInfillPercent(20); - session.setSupportsEnabled(false); - session.setSetupCostChf(BigDecimal.ZERO); - session.setCreatedAt(OffsetDateTime.now()); - session.setExpiresAt(OffsetDateTime.now().plusDays(30)); - } - - if ("CONVERTED".equals(session.getStatus())) { - throw new ResponseStatusException(CONFLICT, "Session already converted to order"); - } - - if (payload.getSourceRequestId() != null) { - if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) { - throw new ResponseStatusException(NOT_FOUND, "Source request not found"); - } - session.setSourceRequestId(payload.getSourceRequestId()); - } else { - session.setSourceRequestId(null); - } - - session.setStatus("CAD_ACTIVE"); - session.setCadHours(cadHours); - session.setCadHourlyRateChf(cadRate); - if (payload.getNotes() != null) { - String trimmedNotes = payload.getNotes().trim(); - session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes); - } - - QuoteSession saved = quoteSessionRepo.save(session); - return ResponseEntity.ok(toCadInvoiceDto(saved)); + return ResponseEntity.ok(adminOperationsControllerService.createOrUpdateCadInvoice(payload)); } @DeleteMapping("/sessions/{sessionId}") @Transactional public ResponseEntity deleteQuoteSession(@PathVariable UUID sessionId) { - QuoteSession session = quoteSessionRepo.findById(sessionId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); - - if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { - throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); - } - - deleteSessionFiles(sessionId); - quoteSessionRepo.delete(session); + adminOperationsControllerService.deleteQuoteSession(sessionId); return ResponseEntity.noContent().build(); } - - private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { - AdminContactRequestDto dto = new AdminContactRequestDto(); - dto.setId(request.getId()); - dto.setRequestType(request.getRequestType()); - dto.setCustomerType(request.getCustomerType()); - dto.setEmail(request.getEmail()); - dto.setPhone(request.getPhone()); - dto.setName(request.getName()); - dto.setCompanyName(request.getCompanyName()); - dto.setStatus(request.getStatus()); - dto.setCreatedAt(request.getCreatedAt()); - return dto; - } - - private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { - AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); - dto.setId(attachment.getId()); - dto.setOriginalFilename(attachment.getOriginalFilename()); - dto.setMimeType(attachment.getMimeType()); - dto.setFileSizeBytes(attachment.getFileSizeBytes()); - dto.setCreatedAt(attachment.getCreatedAt()); - return dto; - } - - private AdminContactRequestDetailDto toContactRequestDetailDto( - CustomQuoteRequest request, - List attachments - ) { - AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); - dto.setId(request.getId()); - dto.setRequestType(request.getRequestType()); - dto.setCustomerType(request.getCustomerType()); - dto.setEmail(request.getEmail()); - dto.setPhone(request.getPhone()); - dto.setName(request.getName()); - dto.setCompanyName(request.getCompanyName()); - dto.setContactPerson(request.getContactPerson()); - dto.setMessage(request.getMessage()); - dto.setStatus(request.getStatus()); - dto.setCreatedAt(request.getCreatedAt()); - dto.setUpdatedAt(request.getUpdatedAt()); - dto.setAttachments(attachments); - return dto; - } - - private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { - AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); - dto.setId(session.getId()); - dto.setStatus(session.getStatus()); - dto.setMaterialCode(session.getMaterialCode()); - dto.setCreatedAt(session.getCreatedAt()); - dto.setExpiresAt(session.getExpiresAt()); - dto.setConvertedOrderId(session.getConvertedOrderId()); - dto.setSourceRequestId(session.getSourceRequestId()); - dto.setCadHours(session.getCadHours()); - dto.setCadHourlyRateChf(session.getCadHourlyRateChf()); - dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session)); - return dto; - } - - private boolean isCadSessionRecord(QuoteSession session) { - if ("CAD_ACTIVE".equals(session.getStatus())) { - return true; - } - if (!"CONVERTED".equals(session.getStatus())) { - return false; - } - BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; - return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null; - } - - private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) { - List items = quoteLineItemRepo.findByQuoteSessionId(session.getId()); - QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); - - AdminCadInvoiceDto dto = new AdminCadInvoiceDto(); - dto.setSessionId(session.getId()); - dto.setSessionStatus(session.getStatus()); - dto.setSourceRequestId(session.getSourceRequestId()); - dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); - dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); - dto.setCadTotalChf(totals.cadTotalChf()); - dto.setPrintItemsTotalChf(totals.printItemsTotalChf()); - dto.setSetupCostChf(totals.setupCostChf()); - dto.setShippingCostChf(totals.shippingCostChf()); - dto.setGrandTotalChf(totals.grandTotalChf()); - dto.setConvertedOrderId(session.getConvertedOrderId()); - dto.setCheckoutPath("/checkout/cad?session=" + session.getId()); - dto.setNotes(session.getNotes()); - dto.setCreatedAt(session.getCreatedAt()); - - if (session.getConvertedOrderId() != null) { - Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null); - dto.setConvertedOrderStatus(order != null ? order.getStatus() : null); - } - return dto; - } - - private void deleteSessionFiles(UUID sessionId) { - Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); - if (!Files.exists(sessionDir)) { - return; - } - - try (Stream walk = Files.walk(sessionDir)) { - walk.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (IOException | UncheckedIOException e) { - logger.error("Failed to delete files for session {}", sessionId, e); - throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); - } - } } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index b098507..d6f5f68 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -8,6 +8,10 @@ public class OrderItemDto { private String originalFilename; private String materialCode; private String colorCode; + private Long filamentVariantId; + private String filamentVariantDisplayName; + private String filamentColorName; + private String filamentColorHex; private String quality; private BigDecimal nozzleDiameterMm; private BigDecimal layerHeightMm; @@ -33,6 +37,18 @@ public class OrderItemDto { public String getColorCode() { return colorCode; } public void setColorCode(String colorCode) { this.colorCode = colorCode; } + public Long getFilamentVariantId() { return filamentVariantId; } + public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; } + + public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; } + public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; } + + public String getFilamentColorName() { return filamentColorName; } + public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; } + + public String getFilamentColorHex() { return filamentColorHex; } + public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } + public String getQuality() { return quality; } public void setQuality(String quality) { this.quality = quality; } diff --git a/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java index 6cfdbb6..94d4bfe 100644 --- a/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/NozzleOptionRepository.java @@ -3,5 +3,9 @@ package com.printcalculator.repository; import com.printcalculator.entity.NozzleOption; import org.springframework.data.jpa.repository.JpaRepository; +import java.math.BigDecimal; +import java.util.Optional; + public interface NozzleOptionRepository extends JpaRepository { -} \ No newline at end of file + Optional findFirstByNozzleDiameterMmAndIsActiveTrue(BigDecimal nozzleDiameterMm); +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 33236fc..ebef7d8 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -157,7 +157,7 @@ public class OrderService { order.setSubtotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO); - order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); + order.setSetupCostChf(totals.setupCostChf()); order.setShippingCostChf(totals.shippingCostChf()); order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus())); order.setSourceRequestId(session.getSourceRequestId()); diff --git a/backend/src/main/java/com/printcalculator/service/ProfileManager.java b/backend/src/main/java/com/printcalculator/service/ProfileManager.java index 67eee52..6411bbf 100644 --- a/backend/src/main/java/com/printcalculator/service/ProfileManager.java +++ b/backend/src/main/java/com/printcalculator/service/ProfileManager.java @@ -7,10 +7,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; import java.util.Iterator; import java.util.Optional; import java.util.logging.Logger; @@ -20,16 +24,21 @@ import java.util.HashMap; import java.util.List; import java.util.LinkedHashSet; import java.util.Set; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class ProfileManager { private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); + private static final Pattern LAYER_MM_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)?)mm\\b", Pattern.CASE_INSENSITIVE); private final String profilesRoot; private final Path resolvedProfilesRoot; private final ObjectMapper mapper; private final Map profileAliases; + private volatile List cachedProcessProfiles; public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) { this.profilesRoot = profilesRoot; @@ -68,6 +77,61 @@ public class ProfileManager { return resolveInheritance(profilePath); } + public List findCompatibleProcessLayers(String machineProfileName) { + if (machineProfileName == null || machineProfileName.isBlank()) { + return List.of(); + } + + Set layers = new LinkedHashSet<>(); + for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) { + if (meta.compatiblePrinters().contains(machineProfileName) && meta.layerHeightMm() != null) { + layers.add(meta.layerHeightMm()); + } + } + if (layers.isEmpty()) { + return List.of(); + } + + List sorted = new ArrayList<>(layers); + sorted.sort(Comparator.naturalOrder()); + return sorted; + } + + public Optional findCompatibleProcessProfileName(String machineProfileName, + BigDecimal layerHeightMm, + String qualityHint) { + if (machineProfileName == null || machineProfileName.isBlank() || layerHeightMm == null) { + return Optional.empty(); + } + + BigDecimal normalizedLayer = layerHeightMm.setScale(3, RoundingMode.HALF_UP); + String normalizedQuality = String.valueOf(qualityHint == null ? "" : qualityHint) + .trim() + .toLowerCase(Locale.ROOT); + + List candidates = new ArrayList<>(); + for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) { + if (!meta.compatiblePrinters().contains(machineProfileName)) { + continue; + } + if (meta.layerHeightMm() == null || meta.layerHeightMm().compareTo(normalizedLayer) != 0) { + continue; + } + candidates.add(meta); + } + + if (candidates.isEmpty()) { + return Optional.empty(); + } + + candidates.sort(Comparator + .comparingInt((ProcessProfileMeta meta) -> scoreProcessForQuality(meta.name(), normalizedQuality)) + .reversed() + .thenComparing(ProcessProfileMeta::name, String.CASE_INSENSITIVE_ORDER)); + + return Optional.ofNullable(candidates.get(0).name()); + } + private Path findProfileFile(String name, String type) { if (!Files.isDirectory(resolvedProfilesRoot)) { logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); @@ -215,4 +279,125 @@ public class ProfileManager { } return "any"; } + + private List getOrLoadProcessProfiles() { + List cached = cachedProcessProfiles; + if (cached != null) { + return cached; + } + + synchronized (this) { + if (cachedProcessProfiles != null) { + return cachedProcessProfiles; + } + + List loaded = new ArrayList<>(); + if (!Files.isDirectory(resolvedProfilesRoot)) { + cachedProcessProfiles = Collections.emptyList(); + return cachedProcessProfiles; + } + + try (Stream stream = Files.walk(resolvedProfilesRoot)) { + List processFiles = stream + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) + .filter(path -> pathContainsSegment(path, "process")) + .sorted() + .toList(); + + for (Path processFile : processFiles) { + try { + JsonNode node = mapper.readTree(processFile.toFile()); + if (!"process".equalsIgnoreCase(node.path("type").asText())) { + continue; + } + + String name = node.path("name").asText(""); + if (name.isBlank()) { + continue; + } + + BigDecimal layer = extractLayerHeightFromProfileName(name); + if (layer == null) { + continue; + } + + Set compatiblePrinters = new LinkedHashSet<>(); + JsonNode compatibleNode = node.path("compatible_printers"); + if (compatibleNode.isArray()) { + compatibleNode.forEach(value -> { + String printer = value.asText("").trim(); + if (!printer.isBlank()) { + compatiblePrinters.add(printer); + } + }); + } + + if (compatiblePrinters.isEmpty()) { + continue; + } + + loaded.add(new ProcessProfileMeta(name, layer, compatiblePrinters)); + } catch (Exception ignored) { + // Ignore malformed or non-process JSON files. + } + } + } catch (IOException e) { + logger.warning("Failed to scan process profiles: " + e.getMessage()); + } + + cachedProcessProfiles = List.copyOf(loaded); + return cachedProcessProfiles; + } + } + + private BigDecimal extractLayerHeightFromProfileName(String profileName) { + if (profileName == null) { + return null; + } + Matcher matcher = LAYER_MM_PATTERN.matcher(profileName.trim()); + if (!matcher.find()) { + return null; + } + try { + return new BigDecimal(matcher.group(1)).setScale(3, RoundingMode.HALF_UP); + } catch (NumberFormatException ex) { + return null; + } + } + + private int scoreProcessForQuality(String processName, String qualityHint) { + String normalizedName = String.valueOf(processName == null ? "" : processName) + .toLowerCase(Locale.ROOT); + if (qualityHint == null || qualityHint.isBlank()) { + return 0; + } + + return switch (qualityHint) { + case "draft" -> { + if (normalizedName.contains("extra draft")) yield 30; + if (normalizedName.contains("draft")) yield 20; + if (normalizedName.contains("standard")) yield 10; + yield 0; + } + case "extra_fine", "high", "high_definition" -> { + if (normalizedName.contains("extra fine")) yield 30; + if (normalizedName.contains("high quality")) yield 25; + if (normalizedName.contains("fine")) yield 20; + if (normalizedName.contains("standard")) yield 5; + yield 0; + } + default -> { + if (normalizedName.contains("standard")) yield 30; + if (normalizedName.contains("optimal")) yield 25; + if (normalizedName.contains("strength")) yield 20; + if (normalizedName.contains("high quality")) yield 10; + if (normalizedName.contains("draft")) yield 5; + yield 0; + } + }; + } + + private record ProcessProfileMeta(String name, BigDecimal layerHeightMm, Set compatiblePrinters) { + } } diff --git a/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java index 2d1ae10..591be3f 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteSessionTotalsService.java @@ -3,22 +3,29 @@ package com.printcalculator.service; import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PricingPolicyRepository; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.LinkedHashSet; import java.util.Arrays; import java.util.List; +import java.util.Set; @Service public class QuoteSessionTotalsService { private final PricingPolicyRepository pricingRepo; private final QuoteCalculator quoteCalculator; + private final NozzleOptionRepository nozzleOptionRepo; - public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, QuoteCalculator quoteCalculator) { + public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, + QuoteCalculator quoteCalculator, + NozzleOptionRepository nozzleOptionRepo) { this.pricingRepo = pricingRepo; this.quoteCalculator = quoteCalculator; + this.nozzleOptionRepo = nozzleOptionRepo; } public QuoteSessionTotals compute(QuoteSession session, List items) { @@ -43,7 +50,9 @@ public class QuoteSessionTotalsService { BigDecimal cadTotal = calculateCadTotal(session); BigDecimal itemsTotal = printItemsTotal.add(cadTotal); - BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + BigDecimal baseSetupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + BigDecimal nozzleChangeCost = calculateNozzleChangeCost(items); + BigDecimal setupFee = baseSetupFee.add(nozzleChangeCost).setScale(2, RoundingMode.HALF_UP); BigDecimal shippingCost = calculateShippingCost(items); BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost); @@ -52,6 +61,8 @@ public class QuoteSessionTotalsService { globalMachineCost, cadTotal, itemsTotal, + baseSetupFee.setScale(2, RoundingMode.HALF_UP), + nozzleChangeCost, setupFee, shippingCost, grandTotal, @@ -104,6 +115,36 @@ public class QuoteSessionTotalsService { return BigDecimal.valueOf(2.00); } + private BigDecimal calculateNozzleChangeCost(List items) { + if (items == null || items.isEmpty()) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + + Set uniqueNozzles = new LinkedHashSet<>(); + for (QuoteLineItem item : items) { + if (item == null || item.getNozzleDiameterMm() == null) { + continue; + } + uniqueNozzles.add(item.getNozzleDiameterMm().setScale(2, RoundingMode.HALF_UP)); + } + + BigDecimal totalFee = BigDecimal.ZERO; + for (BigDecimal nozzle : uniqueNozzles) { + BigDecimal nozzleFee = nozzleOptionRepo + .findFirstByNozzleDiameterMmAndIsActiveTrue(nozzle) + .map(option -> option.getExtraNozzleChangeFeeChf() != null + ? option.getExtraNozzleChangeFeeChf() + : BigDecimal.ZERO) + .orElse(BigDecimal.ZERO); + + if (nozzleFee.compareTo(BigDecimal.ZERO) > 0) { + totalFee = totalFee.add(nozzleFee); + } + } + + return totalFee.setScale(2, RoundingMode.HALF_UP); + } + private int normalizeQuantity(Integer quantity) { if (quantity == null || quantity < 1) { return 1; @@ -116,6 +157,8 @@ public class QuoteSessionTotalsService { BigDecimal globalMachineCostChf, BigDecimal cadTotalChf, BigDecimal itemsTotalChf, + BigDecimal baseSetupCostChf, + BigDecimal nozzleChangeCostChf, BigDecimal setupCostChf, BigDecimal shippingCostChf, BigDecimal grandTotalChf, diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java new file mode 100644 index 0000000..1fc2de4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java @@ -0,0 +1,327 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminFilamentControllerService { + private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Set ALLOWED_FINISH_TYPES = Set.of( + "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" + ); + + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderItemRepository orderItemRepo; + + public AdminFilamentControllerService(FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderItemRepository orderItemRepo) { + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderItemRepo = orderItemRepo; + } + + public List getMaterials() { + return materialRepo.findAll().stream() + .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) + .map(this::toMaterialDto) + .toList(); + } + + public List getVariants() { + return variantRepo.findAll().stream() + .sorted(Comparator + .comparing((FilamentVariant variant) -> { + FilamentMaterialType type = variant.getFilamentMaterialType(); + return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; + }, String.CASE_INSENSITIVE_ORDER) + .thenComparing(variant -> variant.getVariantDisplayName() != null ? variant.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) + .map(this::toVariantDto) + .toList(); + } + + @Transactional + public AdminFilamentMaterialTypeDto createMaterial(AdminUpsertFilamentMaterialTypeRequest payload) { + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, null); + + FilamentMaterialType material = new FilamentMaterialType(); + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return toMaterialDto(saved); + } + + @Transactional + public AdminFilamentMaterialTypeDto updateMaterial(Long materialTypeId, AdminUpsertFilamentMaterialTypeRequest payload) { + FilamentMaterialType material = materialRepo.findById(materialTypeId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); + + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, materialTypeId); + + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return toMaterialDto(saved); + } + + @Transactional + public AdminFilamentVariantDto createVariant(AdminUpsertFilamentVariantRequest payload) { + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); + + FilamentVariant variant = new FilamentVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return toVariantDto(saved); + } + + @Transactional + public AdminFilamentVariantDto updateVariant(Long variantId, AdminUpsertFilamentVariantRequest payload) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); + + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return toVariantDto(saved); + } + + @Transactional + public void deleteVariant(Long variantId) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { + throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); + } + + variantRepo.delete(variant); + } + + private void applyMaterialPayload(FilamentMaterialType material, + AdminUpsertFilamentMaterialTypeRequest payload, + String normalizedMaterialCode) { + boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); + boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); + String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null + ? payload.getTechnicalTypeLabel().trim() + : null; + + material.setMaterialCode(normalizedMaterialCode); + material.setIsFlexible(isFlexible); + material.setIsTechnical(isTechnical); + material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() + ? technicalTypeLabel + : null); + } + + private void applyVariantPayload(FilamentVariant variant, + AdminUpsertFilamentVariantRequest payload, + FilamentMaterialType material, + String normalizedDisplayName, + String normalizedColorName) { + String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); + String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); + String normalizedBrand = normalizeOptional(payload.getBrand()); + + variant.setFilamentMaterialType(material); + variant.setVariantDisplayName(normalizedDisplayName); + variant.setColorName(normalizedColorName); + variant.setColorHex(normalizedColorHex); + variant.setFinishType(normalizedFinishType); + variant.setBrand(normalizedBrand); + variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); + variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); + variant.setCostChfPerKg(payload.getCostChfPerKg()); + variant.setStockSpools(payload.getStockSpools()); + variant.setSpoolNetKg(payload.getSpoolNetKg()); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + } + + private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { + if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); + } + return payload.getMaterialCode().trim().toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateVariantDisplayName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorHex(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { + String normalized = finishType == null || finishType.isBlank() + ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") + : finishType.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_FINISH_TYPES.contains(normalized)) { + throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { + if (payload == null || payload.getMaterialTypeId() == null) { + throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); + } + + return materialRepo.findById(payload.getMaterialTypeId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); + } + + private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { + if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); + } + validateNumeric63(payload.getStockSpools(), "Stock spools", true); + validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); + } + + private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { + if (value == null) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); + } + + if (allowZero) { + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); + } + } else if (value.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); + } + + if (value.scale() > 3) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); + } + + if (value.compareTo(MAX_NUMERIC_6_3) > 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); + } + } + + private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { + materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { + if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { + throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); + } + }); + } + + private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { + variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { + if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); + } + }); + } + + private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { + AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); + dto.setId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setIsFlexible(material.getIsFlexible()); + dto.setIsTechnical(material.getIsTechnical()); + dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); + return dto; + } + + private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { + AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); + dto.setId(variant.getId()); + + FilamentMaterialType material = variant.getFilamentMaterialType(); + if (material != null) { + dto.setMaterialTypeId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setMaterialIsFlexible(material.getIsFlexible()); + dto.setMaterialIsTechnical(material.getIsTechnical()); + dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); + } + + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setFinishType(variant.getFinishType()); + dto.setBrand(variant.getBrand()); + dto.setIsMatte(variant.getIsMatte()); + dto.setIsSpecial(variant.getIsSpecial()); + dto.setCostChfPerKg(variant.getCostChfPerKg()); + dto.setStockSpools(variant.getStockSpools()); + dto.setSpoolNetKg(variant.getSpoolNetKg()); + BigDecimal stockKg = BigDecimal.ZERO; + if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { + stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); + } + dto.setStockKg(stockKg); + dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); + dto.setIsActive(variant.getIsActive()); + dto.setCreatedAt(variant.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java new file mode 100644 index 0000000..1291c1a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java @@ -0,0 +1,469 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCadInvoiceCreateRequest; +import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestAttachmentDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminContactRequestDto; +import com.printcalculator.dto.AdminFilamentStockDto; +import com.printcalculator.dto.AdminQuoteSessionDto; +import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantStockKg; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.domain.Sort; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminOperationsControllerService { + private static final Logger logger = LoggerFactory.getLogger(AdminOperationsControllerService.class); + private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Set CONTACT_REQUEST_ALLOWED_STATUSES = Set.of( + "NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED" + ); + + private final FilamentVariantStockKgRepository filamentStockRepo; + private final FilamentVariantRepository filamentVariantRepo; + private final CustomQuoteRequestRepository customQuoteRequestRepo; + private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderRepository orderRepo; + private final PricingPolicyRepository pricingRepo; + private final QuoteSessionTotalsService quoteSessionTotalsService; + + public AdminOperationsControllerService(FilamentVariantStockKgRepository filamentStockRepo, + FilamentVariantRepository filamentVariantRepo, + CustomQuoteRequestRepository customQuoteRequestRepo, + CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, + QuoteSessionRepository quoteSessionRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderRepository orderRepo, + PricingPolicyRepository pricingRepo, + QuoteSessionTotalsService quoteSessionTotalsService) { + this.filamentStockRepo = filamentStockRepo; + this.filamentVariantRepo = filamentVariantRepo; + this.customQuoteRequestRepo = customQuoteRequestRepo; + this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderRepo = orderRepo; + this.pricingRepo = pricingRepo; + this.quoteSessionTotalsService = quoteSessionTotalsService; + } + + public List getFilamentStock() { + List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); + Set variantIds = stocks.stream() + .map(FilamentVariantStockKg::getFilamentVariantId) + .collect(Collectors.toSet()); + + Map variantsById; + if (variantIds.isEmpty()) { + variantsById = Collections.emptyMap(); + } else { + variantsById = filamentVariantRepo.findAllById(variantIds).stream() + .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); + } + + return stocks.stream().map(stock -> { + FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); + AdminFilamentStockDto dto = new AdminFilamentStockDto(); + dto.setFilamentVariantId(stock.getFilamentVariantId()); + dto.setStockSpools(stock.getStockSpools()); + dto.setSpoolNetKg(stock.getSpoolNetKg()); + dto.setStockKg(stock.getStockKg()); + BigDecimal grams = stock.getStockKg() != null + ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) + : BigDecimal.ZERO; + dto.setStockFilamentGrams(grams); + + if (variant != null) { + dto.setMaterialCode( + variant.getFilamentMaterialType() != null + ? variant.getFilamentMaterialType().getMaterialCode() + : "UNKNOWN" + ); + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setActive(variant.getIsActive()); + } else { + dto.setMaterialCode("UNKNOWN"); + dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); + dto.setColorName("-"); + dto.setActive(false); + } + + return dto; + }).toList(); + } + + public List getContactRequests() { + return customQuoteRequestRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt")) + .stream() + .map(this::toContactRequestDto) + .toList(); + } + + public AdminContactRequestDetailDto getContactRequestDetail(UUID requestId) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return toContactRequestDetailDto(request, attachments); + } + + @Transactional + public AdminContactRequestDetailDto updateContactRequestStatus(UUID requestId, + AdminUpdateContactRequestStatusRequest payload) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + String requestedStatus = payload != null && payload.getStatus() != null + ? payload.getStatus().trim().toUpperCase(Locale.ROOT) + : ""; + + if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES) + ); + } + + request.setStatus(requestedStatus); + request.setUpdatedAt(OffsetDateTime.now()); + CustomQuoteRequest saved = customQuoteRequestRepo.save(request); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return toContactRequestDetailDto(saved, attachments); + } + + public ResponseEntity downloadContactRequestAttachment(UUID requestId, UUID attachmentId) { + CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); + + if (!attachment.getRequest().getId().equals(requestId)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); + } + + String relativePath = attachment.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; + if (!relativePath.startsWith(expectedPrefix)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); + if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + if (!Files.exists(filePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + try { + Resource resource = new UrlResource(filePath.toUri()); + if (!resource.exists() || !resource.isReadable()) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + String mimeType = attachment.getMimeType(); + if (mimeType != null && !mimeType.isBlank()) { + try { + mediaType = MediaType.parseMediaType(mimeType); + } catch (Exception ignored) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = attachment.getOriginalFilename(); + if (filename == null || filename.isBlank()) { + filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() + ? attachment.getStoredFilename() + : "attachment-" + attachmentId; + } + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (MalformedURLException e) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + } + + public List getQuoteSessions() { + return quoteSessionRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt")) + .stream() + .map(this::toQuoteSessionDto) + .toList(); + } + + public List getCadInvoices() { + return quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED")) + .stream() + .filter(this::isCadSessionRecord) + .map(this::toCadInvoiceDto) + .toList(); + } + + @Transactional + public AdminCadInvoiceDto createOrUpdateCadInvoice(AdminCadInvoiceCreateRequest payload) { + if (payload == null || payload.getCadHours() == null) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours is required"); + } + + BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP); + if (cadHours.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0"); + } + + BigDecimal cadRate = payload.getCadHourlyRateChf(); + if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) { + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + cadRate = policy != null && policy.getCadCostChfPerHour() != null + ? policy.getCadCostChfPerHour() + : BigDecimal.ZERO; + } + cadRate = cadRate.setScale(2, RoundingMode.HALF_UP); + + QuoteSession session; + if (payload.getSessionId() != null) { + session = quoteSessionRepo.findById(payload.getSessionId()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + } else { + session = new QuoteSession(); + session.setStatus("CAD_ACTIVE"); + session.setPricingVersion("v1"); + session.setMaterialCode("PLA"); + session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); + session.setLayerHeightMm(BigDecimal.valueOf(0.2)); + session.setInfillPattern("grid"); + session.setInfillPercent(20); + session.setSupportsEnabled(false); + session.setSetupCostChf(BigDecimal.ZERO); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + } + + if ("CONVERTED".equals(session.getStatus())) { + throw new ResponseStatusException(CONFLICT, "Session already converted to order"); + } + + if (payload.getSourceRequestId() != null) { + if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) { + throw new ResponseStatusException(NOT_FOUND, "Source request not found"); + } + session.setSourceRequestId(payload.getSourceRequestId()); + } else { + session.setSourceRequestId(null); + } + + session.setStatus("CAD_ACTIVE"); + session.setCadHours(cadHours); + session.setCadHourlyRateChf(cadRate); + if (payload.getNotes() != null) { + String trimmedNotes = payload.getNotes().trim(); + session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes); + } + + QuoteSession saved = quoteSessionRepo.save(session); + return toCadInvoiceDto(saved); + } + + @Transactional + public void deleteQuoteSession(UUID sessionId) { + QuoteSession session = quoteSessionRepo.findById(sessionId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + + if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { + throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); + } + + deleteSessionFiles(sessionId); + quoteSessionRepo.delete(session); + } + + private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { + AdminContactRequestDto dto = new AdminContactRequestDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + return dto; + } + + private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { + AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); + dto.setId(attachment.getId()); + dto.setOriginalFilename(attachment.getOriginalFilename()); + dto.setMimeType(attachment.getMimeType()); + dto.setFileSizeBytes(attachment.getFileSizeBytes()); + dto.setCreatedAt(attachment.getCreatedAt()); + return dto; + } + + private AdminContactRequestDetailDto toContactRequestDetailDto(CustomQuoteRequest request, + List attachments) { + AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setContactPerson(request.getContactPerson()); + dto.setMessage(request.getMessage()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + dto.setUpdatedAt(request.getUpdatedAt()); + dto.setAttachments(attachments); + return dto; + } + + private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { + AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); + dto.setId(session.getId()); + dto.setStatus(session.getStatus()); + dto.setMaterialCode(session.getMaterialCode()); + dto.setCreatedAt(session.getCreatedAt()); + dto.setExpiresAt(session.getExpiresAt()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours()); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf()); + dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session)); + return dto; + } + + private boolean isCadSessionRecord(QuoteSession session) { + if ("CAD_ACTIVE".equals(session.getStatus())) { + return true; + } + if (!"CONVERTED".equals(session.getStatus())) { + return false; + } + BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO; + return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null; + } + + private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) { + List items = quoteLineItemRepo.findByQuoteSessionId(session.getId()); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + + AdminCadInvoiceDto dto = new AdminCadInvoiceDto(); + dto.setSessionId(session.getId()); + dto.setSessionStatus(session.getStatus()); + dto.setSourceRequestId(session.getSourceRequestId()); + dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO); + dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO); + dto.setCadTotalChf(totals.cadTotalChf()); + dto.setPrintItemsTotalChf(totals.printItemsTotalChf()); + dto.setSetupCostChf(totals.setupCostChf()); + dto.setShippingCostChf(totals.shippingCostChf()); + dto.setGrandTotalChf(totals.grandTotalChf()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + dto.setCheckoutPath("/checkout/cad?session=" + session.getId()); + dto.setNotes(session.getNotes()); + dto.setCreatedAt(session.getCreatedAt()); + + if (session.getConvertedOrderId() != null) { + Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null); + dto.setConvertedOrderStatus(order != null ? order.getStatus() : null); + } + return dto; + } + + private void deleteSessionFiles(UUID sessionId) { + Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); + if (!Files.exists(sessionDir)) { + return; + } + + try (Stream walk = Files.walk(sessionDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException | UncheckedIOException e) { + logger.error("Failed to delete files for session {}", sessionId, e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index 22327e0..dfda322 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -263,6 +263,18 @@ public class AdminOrderControllerService { itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getFilamentVariant() != null) { + itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); + itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); + itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); + } + itemDto.setQuality(item.getQuality()); + itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); + itemDto.setLayerHeightMm(item.getLayerHeightMm()); + itemDto.setInfillPercent(item.getInfillPercent()); + itemDto.setInfillPattern(item.getInfillPattern()); + itemDto.setSupportsEnabled(item.getSupportsEnabled()); itemDto.setQuantity(item.getQuantity()); itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); itemDto.setMaterialGrams(item.getMaterialGrams()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 03d6163..4baca4b 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -317,6 +317,12 @@ public class OrderControllerService { itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getFilamentVariant() != null) { + itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); + itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); + itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); + } itemDto.setQuality(item.getQuality()); itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); itemDto.setLayerHeightMm(item.getLayerHeightMm()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index 9797c7f..38175b0 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -11,6 +11,7 @@ import com.printcalculator.model.QuoteResult; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.ProfileManager; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.SlicerService; import com.printcalculator.service.storage.ClamAVService; @@ -41,6 +42,7 @@ public class QuoteSessionItemService { private final ClamAVService clamAVService; private final QuoteStorageService quoteStorageService; private final QuoteSessionSettingsService settingsService; + private final ProfileManager profileManager; public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo, QuoteSessionRepository sessionRepo, @@ -49,7 +51,8 @@ public class QuoteSessionItemService { OrcaProfileResolver orcaProfileResolver, ClamAVService clamAVService, QuoteStorageService quoteStorageService, - QuoteSessionSettingsService settingsService) { + QuoteSessionSettingsService settingsService, + ProfileManager profileManager) { this.lineItemRepo = lineItemRepo; this.sessionRepo = sessionRepo; this.slicerService = slicerService; @@ -58,6 +61,7 @@ public class QuoteSessionItemService { this.clamAVService = clamAVService; this.quoteStorageService = quoteStorageService; this.settingsService = settingsService; + this.profileManager = profileManager; } public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException { @@ -109,7 +113,12 @@ public class QuoteSessionItemService { } OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); - String processProfile = resolveProcessProfile(settings); + String processProfile = resolveProcessProfile( + settings, + profiles.machineProfileName(), + nozzleDiameter, + layerHeight + ); Map processOverrides = new HashMap<>(); processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString()); @@ -180,7 +189,29 @@ public class QuoteSessionItemService { } } - private String resolveProcessProfile(PrintSettingsDto settings) { + private String resolveProcessProfile(PrintSettingsDto settings, + String machineProfileName, + BigDecimal nozzleDiameter, + BigDecimal layerHeight) { + if (machineProfileName == null || machineProfileName.isBlank() || layerHeight == null) { + return resolveLegacyProcessProfile(settings); + } + + String qualityHint = settingsService.resolveQuality(settings, layerHeight); + return profileManager + .findCompatibleProcessProfileName(machineProfileName, layerHeight, qualityHint) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Layer height " + layerHeight.stripTrailingZeros().toPlainString() + + " is not available for nozzle " + + (nozzleDiameter != null + ? nozzleDiameter.stripTrailingZeros().toPlainString() + : "-") + + " on printer profile " + machineProfileName + )); + } + + private String resolveLegacyProcessProfile(PrintSettingsDto settings) { if (settings.getLayerHeight() == null) { return "standard"; } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index f5e8721..3652586 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -34,6 +34,9 @@ public class QuoteSessionResponseAssembler { response.put("printItemsTotalChf", totals.printItemsTotalChf()); response.put("cadTotalChf", totals.cadTotalChf()); response.put("itemsTotalChf", totals.itemsTotalChf()); + response.put("baseSetupCostChf", totals.baseSetupCostChf()); + response.put("nozzleChangeCostChf", totals.nozzleChangeCostChf()); + response.put("setupCostChf", totals.setupCostChf()); response.put("shippingCostChf", totals.shippingCostChf()); response.put("globalMachineCostChf", totals.globalMachineCostChf()); response.put("grandTotalChf", totals.grandTotalChf()); diff --git a/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java b/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java new file mode 100644 index 0000000..60f6890 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/ContactRequestLocalizationService.java @@ -0,0 +1,216 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Service +public class ContactRequestLocalizationService { + + public String applyCustomerContactRequestTexts(Map templateData, String language, UUID requestId) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Contact request received"); + templateData.put("headlineText", "We received your contact request"); + templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ","); + templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible."); + templateData.put("requestIdHintText", "Please keep this request ID for future order references:"); + templateData.put("detailsTitleText", "Request details"); + templateData.put("labelRequestId", "Request ID"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Request type"); + templateData.put("labelCustomerType", "Customer type"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Company"); + templateData.put("labelContactPerson", "Contact person"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Phone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Attachments"); + templateData.put("supportText", "If you need help, reply to this email."); + templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab."); + yield "We received your contact request #" + requestId + " - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Kontaktanfrage erhalten"); + templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten"); + templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ","); + templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich."); + templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:"); + templateData.put("detailsTitleText", "Anfragedetails"); + templateData.put("labelRequestId", "Anfrage-ID"); + templateData.put("labelDate", "Datum"); + templateData.put("labelRequestType", "Anfragetyp"); + templateData.put("labelCustomerType", "Kundentyp"); + templateData.put("labelName", "Name"); + templateData.put("labelCompany", "Firma"); + templateData.put("labelContactPerson", "Kontaktperson"); + templateData.put("labelEmail", "E-Mail"); + templateData.put("labelPhone", "Telefon"); + templateData.put("labelMessage", "Nachricht"); + templateData.put("labelAttachments", "Anhaenge"); + templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail."); + templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab."); + yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Demande de contact recue"); + templateData.put("headlineText", "Nous avons recu votre demande de contact"); + templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ","); + templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible."); + templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :"); + templateData.put("detailsTitleText", "Details de la demande"); + templateData.put("labelRequestId", "ID de demande"); + templateData.put("labelDate", "Date"); + templateData.put("labelRequestType", "Type de demande"); + templateData.put("labelCustomerType", "Type de client"); + templateData.put("labelName", "Nom"); + templateData.put("labelCompany", "Entreprise"); + templateData.put("labelContactPerson", "Contact"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telephone"); + templateData.put("labelMessage", "Message"); + templateData.put("labelAttachments", "Pieces jointes"); + templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email."); + templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab."); + yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Richiesta di contatto ricevuta"); + templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto"); + templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ","); + templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile."); + templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:"); + templateData.put("detailsTitleText", "Dettagli richiesta"); + templateData.put("labelRequestId", "ID richiesta"); + templateData.put("labelDate", "Data"); + templateData.put("labelRequestType", "Tipo richiesta"); + templateData.put("labelCustomerType", "Tipo cliente"); + templateData.put("labelName", "Nome"); + templateData.put("labelCompany", "Azienda"); + templateData.put("labelContactPerson", "Contatto"); + templateData.put("labelEmail", "Email"); + templateData.put("labelPhone", "Telefono"); + templateData.put("labelMessage", "Messaggio"); + templateData.put("labelAttachments", "Allegati"); + templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email."); + templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab."); + yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab"; + } + }; + } + + public String localizeRequestType(String requestType, String language) { + if (requestType == null || requestType.isBlank()) { + return "-"; + } + + String normalized = requestType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "custom", "print_service" -> "Custom part request"; + case "series" -> "Series production request"; + case "consult", "design_service" -> "Consultation request"; + case "question" -> "General question"; + default -> requestType; + }; + case "de" -> switch (normalized) { + case "custom", "print_service" -> "Anfrage fuer Einzelteil"; + case "series" -> "Anfrage fuer Serienproduktion"; + case "consult", "design_service" -> "Beratungsanfrage"; + case "question" -> "Allgemeine Frage"; + default -> requestType; + }; + case "fr" -> switch (normalized) { + case "custom", "print_service" -> "Demande de piece personnalisee"; + case "series" -> "Demande de production en serie"; + case "consult", "design_service" -> "Demande de conseil"; + case "question" -> "Question generale"; + default -> requestType; + }; + default -> switch (normalized) { + case "custom", "print_service" -> "Richiesta pezzo personalizzato"; + case "series" -> "Richiesta produzione in serie"; + case "consult", "design_service" -> "Richiesta consulenza"; + case "question" -> "Domanda generale"; + default -> requestType; + }; + }; + } + + public String localizeCustomerType(String customerType, String language) { + if (customerType == null || customerType.isBlank()) { + return "-"; + } + String normalized = customerType.trim().toLowerCase(Locale.ROOT); + return switch (language) { + case "en" -> switch (normalized) { + case "private" -> "Private"; + case "business" -> "Business"; + default -> customerType; + }; + case "de" -> switch (normalized) { + case "private" -> "Privat"; + case "business" -> "Unternehmen"; + default -> customerType; + }; + case "fr" -> switch (normalized) { + case "private" -> "Prive"; + case "business" -> "Entreprise"; + default -> customerType; + }; + default -> switch (normalized) { + case "private" -> "Privato"; + case "business" -> "Azienda"; + default -> customerType; + }; + }; + } + + public Locale localeForLanguage(String language) { + return switch (language) { + case "en" -> Locale.ENGLISH; + case "de" -> Locale.GERMAN; + case "fr" -> Locale.FRENCH; + default -> Locale.ITALIAN; + }; + } + + public String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + String normalized = language.toLowerCase(Locale.ROOT).trim(); + if (normalized.startsWith("en")) { + return "en"; + } + if (normalized.startsWith("de")) { + return "de"; + } + if (normalized.startsWith("fr")) { + return "fr"; + } + return "it"; + } + + public String resolveRecipientName(CustomQuoteRequest request, String language) { + if (request.getName() != null && !request.getName().isBlank()) { + return request.getName().trim(); + } + if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { + return request.getContactPerson().trim(); + } + if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) { + return request.getCompanyName().trim(); + } + return switch (language) { + case "en" -> "customer"; + case "de" -> "Kunde"; + case "fr" -> "client"; + default -> "cliente"; + }; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java new file mode 100644 index 0000000..c5ff2b6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentService.java @@ -0,0 +1,155 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.service.storage.ClamAVService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +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.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +@Transactional(readOnly = true) +public class CustomQuoteRequestAttachmentService { + + private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( + "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" + ); + private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/vnd.rar", + "application/x-7z-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-bzip2", + "application/x-xz", + "application/zstd", + "application/x-zstd" + ); + + private final CustomQuoteRequestAttachmentRepository attachmentRepo; + private final ClamAVService clamAVService; + + public CustomQuoteRequestAttachmentService(CustomQuoteRequestAttachmentRepository attachmentRepo, + ClamAVService clamAVService) { + this.attachmentRepo = attachmentRepo; + this.clamAVService = clamAVService; + } + + @Transactional + public int storeAttachments(CustomQuoteRequest request, List files) throws IOException { + if (files == null || files.isEmpty()) { + return 0; + } + if (files.size() > 15) { + throw new IOException("Too many files. Max 15 allowed."); + } + + int attachmentsCount = 0; + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + if (isCompressedFile(file)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Compressed files are not allowed."); + } + + clamAVService.scan(file.getInputStream()); + + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); + attachment.setRequest(request); + attachment.setOriginalFilename(file.getOriginalFilename()); + attachment.setMimeType(file.getContentType()); + attachment.setFileSizeBytes(file.getSize()); + attachment.setCreatedAt(OffsetDateTime.now()); + attachment.setStoredFilename(UUID.randomUUID() + ".upload"); + attachment.setStoredRelativePath("PENDING"); + + attachment = attachmentRepo.save(attachment); + + Path relativePath = Path.of( + "quote-requests", + request.getId().toString(), + "attachments", + attachment.getId().toString(), + attachment.getStoredFilename() + ); + attachment.setStoredRelativePath(relativePath.toString()); + attachmentRepo.save(attachment); + + Path absolutePath = resolveWithinStorageRoot(relativePath); + Files.createDirectories(absolutePath.getParent()); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); + } + attachmentsCount++; + } + + return attachmentsCount; + } + + private String getExtension(String filename) { + if (filename == null) { + return "dat"; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "dat"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "dat"; + } + + private boolean isCompressedFile(MultipartFile file) { + String ext = getExtension(file.getOriginalFilename()); + if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { + return true; + } + String mime = file.getContentType(); + return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase(Locale.ROOT)); + } + + private Path resolveWithinStorageRoot(Path relativePath) { + try { + Path normalizedRelative = relativePath.normalize(); + if (normalizedRelative.isAbsolute()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); + if (!absolutePath.startsWith(STORAGE_ROOT)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + return absolutePath; + } catch (InvalidPathException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java new file mode 100644 index 0000000..d2bf728 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestControllerService.java @@ -0,0 +1,68 @@ +package com.printcalculator.service.request; + +import com.printcalculator.dto.QuoteRequestDto; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class CustomQuoteRequestControllerService { + + private final CustomQuoteRequestRepository requestRepo; + private final CustomQuoteRequestAttachmentService attachmentService; + private final CustomQuoteRequestNotificationService notificationService; + + public CustomQuoteRequestControllerService(CustomQuoteRequestRepository requestRepo, + CustomQuoteRequestAttachmentService attachmentService, + CustomQuoteRequestNotificationService notificationService) { + this.requestRepo = requestRepo; + this.attachmentService = attachmentService; + this.notificationService = notificationService; + } + + @Transactional + public CustomQuoteRequest createCustomQuoteRequest(QuoteRequestDto requestDto, List files) throws IOException { + validateConsents(requestDto); + + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setRequestType(requestDto.getRequestType()); + request.setCustomerType(requestDto.getCustomerType()); + request.setEmail(requestDto.getEmail()); + request.setPhone(requestDto.getPhone()); + request.setName(requestDto.getName()); + request.setCompanyName(requestDto.getCompanyName()); + request.setContactPerson(requestDto.getContactPerson()); + request.setMessage(requestDto.getMessage()); + request.setStatus("PENDING"); + request.setCreatedAt(OffsetDateTime.now()); + request.setUpdatedAt(OffsetDateTime.now()); + + request = requestRepo.save(request); + + int attachmentsCount = attachmentService.storeAttachments(request, files); + notificationService.sendNotifications(request, attachmentsCount, requestDto.getLanguage()); + + return request; + } + + public Optional getCustomQuoteRequest(UUID id) { + return requestRepo.findById(id); + } + + private void validateConsents(QuoteRequestDto requestDto) { + if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Accettazione Termini e Privacy obbligatoria."); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java new file mode 100644 index 0000000..6c6d53c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.service.email.EmailNotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.HashMap; +import java.util.Map; + +@Service +public class CustomQuoteRequestNotificationService { + + private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestNotificationService.class); + + private final EmailNotificationService emailNotificationService; + private final ContactRequestLocalizationService localizationService; + + @Value("${app.mail.contact-request.admin.enabled:true}") + private boolean contactRequestAdminMailEnabled; + + @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") + private String contactRequestAdminMailAddress; + + @Value("${app.mail.contact-request.customer.enabled:true}") + private boolean contactRequestCustomerMailEnabled; + + public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, + ContactRequestLocalizationService localizationService) { + this.emailNotificationService = emailNotificationService; + this.localizationService = localizationService; + } + + public void sendNotifications(CustomQuoteRequest request, int attachmentsCount, String rawLanguage) { + String language = localizationService.normalizeLanguage(rawLanguage); + sendAdminContactRequestNotification(request, attachmentsCount); + sendCustomerContactRequestConfirmation(request, attachmentsCount, language); + } + + private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) { + if (!contactRequestAdminMailEnabled) { + return; + } + if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) { + logger.warn("Contact request admin notification enabled but no admin address configured."); + return; + } + + Map templateData = new HashMap<>(); + templateData.put("requestId", request.getId()); + templateData.put("createdAt", request.getCreatedAt()); + templateData.put("requestType", safeValue(request.getRequestType())); + templateData.put("customerType", safeValue(request.getCustomerType())); + templateData.put("name", safeValue(request.getName())); + templateData.put("companyName", safeValue(request.getCompanyName())); + templateData.put("contactPerson", safeValue(request.getContactPerson())); + templateData.put("email", safeValue(request.getEmail())); + templateData.put("phone", safeValue(request.getPhone())); + templateData.put("message", safeValue(request.getMessage())); + templateData.put("attachmentsCount", attachmentsCount); + templateData.put("currentYear", Year.now().getValue()); + + emailNotificationService.sendEmail( + contactRequestAdminMailAddress, + "Nuova richiesta di contatto #" + request.getId(), + "contact-request-admin", + templateData + ); + } + + private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) { + if (!contactRequestCustomerMailEnabled) { + return; + } + if (request.getEmail() == null || request.getEmail().isBlank()) { + logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId()); + return; + } + + Map templateData = new HashMap<>(); + templateData.put("requestId", request.getId()); + templateData.put( + "createdAt", + request.getCreatedAt().format( + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(localizationService.localeForLanguage(language)) + ) + ); + templateData.put("recipientName", localizationService.resolveRecipientName(request, language)); + templateData.put("requestType", localizationService.localizeRequestType(request.getRequestType(), language)); + templateData.put("customerType", localizationService.localizeCustomerType(request.getCustomerType(), language)); + templateData.put("name", safeValue(request.getName())); + templateData.put("companyName", safeValue(request.getCompanyName())); + templateData.put("contactPerson", safeValue(request.getContactPerson())); + templateData.put("email", safeValue(request.getEmail())); + templateData.put("phone", safeValue(request.getPhone())); + templateData.put("message", safeValue(request.getMessage())); + templateData.put("attachmentsCount", attachmentsCount); + templateData.put("currentYear", Year.now().getValue()); + + String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); + + emailNotificationService.sendEmail( + request.getEmail(), + subject, + "contact-request-customer", + templateData + ); + } + + private String safeValue(String value) { + if (value == null || value.isBlank()) { + return "-"; + } + return value; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java index 861b5f6..7a6d4a0 100644 --- a/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/QuoteSessionTotalsServiceTest.java @@ -3,6 +3,8 @@ package com.printcalculator.service; import com.printcalculator.entity.PricingPolicy; import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.NozzleOption; +import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PricingPolicyRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,13 +22,15 @@ import static org.mockito.Mockito.when; class QuoteSessionTotalsServiceTest { private PricingPolicyRepository pricingRepo; private QuoteCalculator quoteCalculator; + private NozzleOptionRepository nozzleOptionRepo; private QuoteSessionTotalsService service; @BeforeEach void setUp() { pricingRepo = mock(PricingPolicyRepository.class); quoteCalculator = mock(QuoteCalculator.class); - service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator); + nozzleOptionRepo = mock(NozzleOptionRepository.class); + service = new QuoteSessionTotalsService(pricingRepo, quoteCalculator, nozzleOptionRepo); } @Test @@ -77,6 +81,51 @@ class QuoteSessionTotalsServiceTest { assertAmountEquals("120.00", totals.grandTotalChf()); } + @Test + void compute_WithRepeatedNozzleAcrossItems_ShouldChargeNozzleFeeOnlyOncePerType() { + QuoteSession session = new QuoteSession(); + session.setSetupCostChf(new BigDecimal("2.00")); + + QuoteLineItem itemA = createItem(new BigDecimal("10.00"), 3, 3600, "0.60"); + QuoteLineItem itemB = createItem(new BigDecimal("4.00"), 2, 1200, "0.60"); + QuoteLineItem itemC = createItem(new BigDecimal("5.00"), 1, 600, "0.80"); + + PricingPolicy policy = new PricingPolicy(); + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteCalculator.calculateSessionMachineCost(eq(policy), any(BigDecimal.class))).thenReturn(BigDecimal.ZERO); + when(nozzleOptionRepo.findFirstByNozzleDiameterMmAndIsActiveTrue(new BigDecimal("0.60"))) + .thenReturn(java.util.Optional.of(nozzleOption("0.60", "1.50"))); + when(nozzleOptionRepo.findFirstByNozzleDiameterMmAndIsActiveTrue(new BigDecimal("0.80"))) + .thenReturn(java.util.Optional.of(nozzleOption("0.80", "1.50"))); + + QuoteSessionTotalsService.QuoteSessionTotals totals = service.compute(session, List.of(itemA, itemB, itemC)); + + assertAmountEquals("43.00", totals.itemsTotalChf()); + assertAmountEquals("3.00", totals.nozzleChangeCostChf()); + assertAmountEquals("5.00", totals.setupCostChf()); + assertAmountEquals("50.00", totals.grandTotalChf()); + } + + private QuoteLineItem createItem(BigDecimal unitPrice, int quantity, int printSeconds, String nozzleMm) { + QuoteLineItem item = new QuoteLineItem(); + item.setQuantity(quantity); + item.setUnitPriceChf(unitPrice); + item.setPrintTimeSeconds(printSeconds); + item.setNozzleDiameterMm(new BigDecimal(nozzleMm)); + item.setBoundingBoxXMm(new BigDecimal("10")); + item.setBoundingBoxYMm(new BigDecimal("10")); + item.setBoundingBoxZMm(new BigDecimal("10")); + return item; + } + + private NozzleOption nozzleOption(String diameterMm, String feeChf) { + NozzleOption option = new NozzleOption(); + option.setNozzleDiameterMm(new BigDecimal(diameterMm)); + option.setExtraNozzleChangeFeeChf(new BigDecimal(feeChf)); + option.setIsActive(true); + return option; + } + private void assertAmountEquals(String expected, BigDecimal actual) { assertTrue(new BigDecimal(expected).compareTo(actual) == 0, "Expected " + expected + " but got " + actual); diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java new file mode 100644 index 0000000..57e15d9 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminFilamentControllerServiceTest.java @@ -0,0 +1,174 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminFilamentControllerServiceTest { + + @Mock + private FilamentMaterialTypeRepository materialRepo; + @Mock + private FilamentVariantRepository variantRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private OrderItemRepository orderItemRepo; + + @InjectMocks + private AdminFilamentControllerService service; + + @Test + void createMaterial_withDuplicateCode_shouldReturnBadRequest() { + AdminUpsertFilamentMaterialTypeRequest payload = new AdminUpsertFilamentMaterialTypeRequest(); + payload.setMaterialCode("pla"); + + FilamentMaterialType existing = new FilamentMaterialType(); + existing.setId(1L); + existing.setMaterialCode("PLA"); + when(materialRepo.findByMaterialCode("PLA")).thenReturn(Optional.of(existing)); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createMaterial(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(materialRepo, never()).save(any(FilamentMaterialType.class)); + } + + @Test + void createVariant_withInvalidColorHex_shouldReturnBadRequest() { + FilamentMaterialType material = new FilamentMaterialType(); + material.setId(10L); + material.setMaterialCode("PLA"); + when(materialRepo.findById(10L)).thenReturn(Optional.of(material)); + when(variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, "Sunset Orange")) + .thenReturn(Optional.empty()); + + AdminUpsertFilamentVariantRequest payload = baseVariantPayload(); + payload.setColorHex("#12"); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createVariant(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(variantRepo, never()).save(any(FilamentVariant.class)); + } + + @Test + void createVariant_withValidPayload_shouldNormalizeDerivedFields() { + FilamentMaterialType material = new FilamentMaterialType(); + material.setId(10L); + material.setMaterialCode("PLA"); + when(materialRepo.findById(10L)).thenReturn(Optional.of(material)); + when(variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, "Sunset Orange")) + .thenReturn(Optional.empty()); + when(variantRepo.save(any(FilamentVariant.class))).thenAnswer(invocation -> { + FilamentVariant variant = invocation.getArgument(0); + variant.setId(42L); + return variant; + }); + + AdminUpsertFilamentVariantRequest payload = baseVariantPayload(); + payload.setFinishType("matte"); + payload.setIsMatte(false); + payload.setBrand(" Prusa "); + payload.setIsActive(null); + + AdminFilamentVariantDto dto = service.createVariant(payload); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FilamentVariant.class); + verify(variantRepo).save(captor.capture()); + FilamentVariant saved = captor.getValue(); + + assertEquals(42L, dto.getId()); + assertEquals("MATTE", saved.getFinishType()); + assertTrue(saved.getIsMatte()); + assertEquals("Prusa", saved.getBrand()); + assertTrue(saved.getIsActive()); + } + + @Test + void deleteVariant_whenInUse_shouldReturnConflict() { + Long variantId = 11L; + FilamentVariant variant = new FilamentVariant(); + variant.setId(variantId); + + when(variantRepo.findById(variantId)).thenReturn(Optional.of(variant)); + when(quoteLineItemRepo.existsByFilamentVariant_Id(variantId)).thenReturn(true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.deleteVariant(variantId) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + verify(variantRepo, never()).delete(any(FilamentVariant.class)); + } + + @Test + void getMaterials_shouldReturnAlphabeticalByCode() { + FilamentMaterialType abs = new FilamentMaterialType(); + abs.setId(2L); + abs.setMaterialCode("ABS"); + + FilamentMaterialType pla = new FilamentMaterialType(); + pla.setId(1L); + pla.setMaterialCode("PLA"); + + when(materialRepo.findAll()).thenReturn(List.of(pla, abs)); + + List result = service.getMaterials(); + + assertEquals(2, result.size()); + assertEquals("ABS", result.get(0).getMaterialCode()); + assertEquals("PLA", result.get(1).getMaterialCode()); + } + + private AdminUpsertFilamentVariantRequest baseVariantPayload() { + AdminUpsertFilamentVariantRequest payload = new AdminUpsertFilamentVariantRequest(); + payload.setMaterialTypeId(10L); + payload.setVariantDisplayName("Sunset Orange"); + payload.setColorName("Orange"); + payload.setColorHex("#FF8800"); + payload.setFinishType("GLOSSY"); + payload.setIsMatte(false); + payload.setIsSpecial(false); + payload.setCostChfPerKg(new BigDecimal("29.90")); + payload.setStockSpools(new BigDecimal("2.000")); + payload.setSpoolNetKg(new BigDecimal("1.000")); + payload.setIsActive(true); + return payload; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java new file mode 100644 index 0000000..f70ffd4 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminOperationsControllerServiceTest.java @@ -0,0 +1,211 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCadInvoiceCreateRequest; +import com.printcalculator.dto.AdminCadInvoiceDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.entity.PricingPolicy; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminOperationsControllerServiceTest { + + @Mock + private FilamentVariantStockKgRepository filamentStockRepo; + @Mock + private FilamentVariantRepository filamentVariantRepo; + @Mock + private CustomQuoteRequestRepository customQuoteRequestRepo; + @Mock + private CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private OrderRepository orderRepo; + @Mock + private PricingPolicyRepository pricingRepo; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + + @InjectMocks + private AdminOperationsControllerService service; + + @Test + void updateContactRequestStatus_withInvalidStatus_shouldReturnBadRequest() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + request.setStatus("PENDING"); + when(customQuoteRequestRepo.findById(requestId)).thenReturn(Optional.of(request)); + + AdminUpdateContactRequestStatusRequest payload = new AdminUpdateContactRequestStatusRequest(); + payload.setStatus("wrong"); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.updateContactRequestStatus(requestId, payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(customQuoteRequestRepo, never()).save(any(CustomQuoteRequest.class)); + } + + @Test + void updateContactRequestStatus_withValidStatus_shouldPersistAndReturnDetail() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + request.setStatus("PENDING"); + request.setCreatedAt(OffsetDateTime.now()); + request.setUpdatedAt(OffsetDateTime.now()); + + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); + attachment.setId(UUID.randomUUID()); + attachment.setOriginalFilename("drawing.stp"); + attachment.setMimeType("application/step"); + attachment.setFileSizeBytes(123L); + attachment.setCreatedAt(OffsetDateTime.now()); + + when(customQuoteRequestRepo.findById(requestId)).thenReturn(Optional.of(request)); + when(customQuoteRequestRepo.save(any(CustomQuoteRequest.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(customQuoteRequestAttachmentRepo.findByRequest_IdOrderByCreatedAtAsc(requestId)).thenReturn(List.of(attachment)); + + AdminUpdateContactRequestStatusRequest payload = new AdminUpdateContactRequestStatusRequest(); + payload.setStatus("done"); + + AdminContactRequestDetailDto dto = service.updateContactRequestStatus(requestId, payload); + + assertEquals("DONE", dto.getStatus()); + assertNotNull(dto.getUpdatedAt()); + assertEquals(1, dto.getAttachments().size()); + verify(customQuoteRequestRepo).save(request); + } + + @Test + void createOrUpdateCadInvoice_withMissingCadHours_shouldReturnBadRequest() { + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createOrUpdateCadInvoice(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + void createOrUpdateCadInvoice_withConvertedSession_shouldReturnConflict() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("CONVERTED"); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + payload.setSessionId(sessionId); + payload.setCadHours(new BigDecimal("1.0")); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createOrUpdateCadInvoice(payload) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + } + + @Test + void createOrUpdateCadInvoice_withNewSession_shouldUsePolicyCadRate() { + PricingPolicy policy = new PricingPolicy(); + policy.setCadCostChfPerHour(new BigDecimal("85")); + + when(pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc()).thenReturn(policy); + when(quoteSessionRepo.save(any(QuoteSession.class))).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + if (session.getId() == null) { + session.setId(UUID.randomUUID()); + } + return session; + }); + when(quoteLineItemRepo.findByQuoteSessionId(any(UUID.class))).thenReturn(List.of()); + when(quoteSessionTotalsService.compute(any(QuoteSession.class), anyList())) + .thenReturn(new QuoteSessionTotalsService.QuoteSessionTotals( + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("212.50"), + new BigDecimal("212.50"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("212.50"), + BigDecimal.ZERO + )); + + AdminCadInvoiceCreateRequest payload = new AdminCadInvoiceCreateRequest(); + payload.setCadHours(new BigDecimal("2.5")); + payload.setCadHourlyRateChf(null); + payload.setNotes(" Custom CAD work "); + + AdminCadInvoiceDto dto = service.createOrUpdateCadInvoice(payload); + + assertEquals("CAD_ACTIVE", dto.getSessionStatus()); + assertEquals(new BigDecimal("2.50"), dto.getCadHours()); + assertEquals(new BigDecimal("85.00"), dto.getCadHourlyRateChf()); + assertEquals("Custom CAD work", dto.getNotes()); + assertEquals(new BigDecimal("212.50"), dto.getCadTotalChf()); + } + + @Test + void deleteQuoteSession_whenLinkedToOrder_shouldReturnConflict() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(orderRepo.existsBySourceQuoteSession_Id(sessionId)).thenReturn(true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.deleteQuoteSession(sessionId) + ); + + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + verify(quoteSessionRepo, never()).delete(any(QuoteSession.class)); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java new file mode 100644 index 0000000..2622e96 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/order/AdminOrderControllerServiceTest.java @@ -0,0 +1,228 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.event.OrderShippedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +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.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminOrderControllerServiceTest { + + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private PaymentRepository paymentRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private PaymentService paymentService; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private AdminOrderControllerService service; + + @Test + void updatePaymentMethod_withBlankMethod_shouldReturnBadRequest() { + UUID orderId = UUID.randomUUID(); + when(orderRepo.findById(orderId)).thenReturn(Optional.of(buildOrder(orderId, "PENDING_PAYMENT"))); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.updatePaymentMethod(orderId, Map.of("method", " ")) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(paymentService, never()).updatePaymentMethod(any(), any()); + } + + @Test + void updatePaymentMethod_withValidMethod_shouldDelegateAndReturnUpdatedDto() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PENDING_PAYMENT"); + Payment payment = new Payment(); + payment.setMethod("BANK_TRANSFER"); + payment.setStatus("PENDING"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.of(payment)); + + OrderDto dto = service.updatePaymentMethod(orderId, Map.of("method", "BANK_TRANSFER")); + + assertEquals("BANK_TRANSFER", dto.getPaymentMethod()); + assertEquals("PENDING", dto.getPaymentStatus()); + verify(paymentService).updatePaymentMethod(orderId, "BANK_TRANSFER"); + } + + @Test + void updateOrderStatus_toShipped_shouldPublishOrderShippedEvent() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PAID"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("shipped"); + + OrderDto dto = service.updateOrderStatus(orderId, payload); + + assertEquals("SHIPPED", dto.getStatus()); + verify(eventPublisher).publishEvent(any(OrderShippedEvent.class)); + } + + @Test + void updateOrderStatus_fromShippedToShipped_shouldNotPublishEvent() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "SHIPPED"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("SHIPPED"); + + service.updateOrderStatus(orderId, payload); + + verify(eventPublisher, never()).publishEvent(any(OrderShippedEvent.class)); + } + + @Test + void downloadOrderItemFile_withInvalidRelativePath_shouldReturnNotFound() { + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = buildOrder(orderId, "PAID"); + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("../escape/path.stl"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.downloadOrderItemFile(orderId, orderItemId) + ); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + + @Test + void getOrder_shouldIncludePerItemPrintSettingsAndVariantMetadata() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PAID"); + + FilamentVariant variant = new FilamentVariant(); + variant.setId(42L); + variant.setVariantDisplayName("PLA Arancione Opaco"); + variant.setColorName("Arancione"); + variant.setColorHex("#ff7a00"); + + OrderItem item = new OrderItem(); + item.setId(UUID.randomUUID()); + item.setOrder(order); + item.setOriginalFilename("obj_4_Part 1.stl"); + item.setMaterialCode("PLA"); + item.setColorCode("Arancione"); + item.setFilamentVariant(variant); + item.setQuality("standard"); + item.setNozzleDiameterMm(new BigDecimal("0.60")); + item.setLayerHeightMm(new BigDecimal("0.24")); + item.setInfillPercent(15); + item.setInfillPattern("grid"); + item.setSupportsEnabled(Boolean.FALSE); + item.setQuantity(1); + item.setPrintTimeSeconds(2340); + item.setMaterialGrams(new BigDecimal("22.76")); + item.setUnitPriceChf(new BigDecimal("0.99")); + item.setLineTotalChf(new BigDecimal("0.99")); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of(item)); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + OrderDto dto = service.getOrder(orderId); + + assertEquals(1, dto.getItems().size()); + var itemDto = dto.getItems().get(0); + assertEquals(new BigDecimal("0.60"), itemDto.getNozzleDiameterMm()); + assertEquals(new BigDecimal("0.24"), itemDto.getLayerHeightMm()); + assertEquals(15, itemDto.getInfillPercent()); + assertEquals("grid", itemDto.getInfillPattern()); + assertEquals(Boolean.FALSE, itemDto.getSupportsEnabled()); + assertEquals(42L, itemDto.getFilamentVariantId()); + assertEquals("PLA Arancione Opaco", itemDto.getFilamentVariantDisplayName()); + assertEquals("Arancione", itemDto.getFilamentColorName()); + assertEquals("#ff7a00", itemDto.getFilamentColorHex()); + } + + private Order buildOrder(UUID orderId, String status) { + Order order = new Order(); + order.setId(orderId); + order.setStatus(status); + order.setCustomerEmail("customer@example.com"); + order.setCustomerPhone("+41910000000"); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Mario"); + order.setBillingLastName("Rossi"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setShippingSameAsBilling(true); + order.setCurrency("CHF"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSubtotalChf(BigDecimal.ZERO); + order.setCadTotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + return order; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java new file mode 100644 index 0000000..7cd15eb --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/order/OrderControllerServiceTest.java @@ -0,0 +1,183 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.payment.TwintPaymentService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderControllerServiceTest { + + @Mock + private OrderService orderService; + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private TwintPaymentService twintPaymentService; + @Mock + private PaymentService paymentService; + @Mock + private PaymentRepository paymentRepo; + + @InjectMocks + private OrderControllerService service; + + @Test + void uploadOrderItemFile_withOrderMismatch_shouldReturnFalse() throws Exception { + UUID expectedOrderId = UUID.randomUUID(); + UUID wrongOrderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = new Order(); + order.setId(expectedOrderId); + + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("PENDING"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + MockMultipartFile file = new MockMultipartFile("file", "part.stl", "model/stl", "solid".getBytes()); + + boolean result = service.uploadOrderItemFile(wrongOrderId, orderItemId, file); + + assertFalse(result); + verify(storageService, never()).store(any(MockMultipartFile.class), any(Path.class)); + verify(orderItemRepo, never()).save(any(OrderItem.class)); + } + + @Test + void uploadOrderItemFile_withPendingPath_shouldStoreAndPersistMetadata() throws Exception { + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + Order order = new Order(); + order.setId(orderId); + + OrderItem item = new OrderItem(); + item.setId(orderItemId); + item.setOrder(order); + item.setStoredRelativePath("PENDING"); + + when(orderItemRepo.findById(orderItemId)).thenReturn(Optional.of(item)); + + MockMultipartFile file = new MockMultipartFile("file", "model.STL", "model/stl", "mesh".getBytes()); + + boolean result = service.uploadOrderItemFile(orderId, orderItemId, file); + + assertTrue(result); + + ArgumentCaptor pathCaptor = ArgumentCaptor.forClass(Path.class); + verify(storageService).store(eq(file), pathCaptor.capture()); + Path storedPath = pathCaptor.getValue(); + assertTrue(storedPath.startsWith(Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()))); + + assertTrue(item.getStoredFilename().endsWith(".stl")); + assertEquals(file.getSize(), item.getFileSizeBytes()); + assertEquals("model/stl", item.getMimeType()); + verify(orderItemRepo).save(item); + } + + @Test + void getOrder_withShippedStatus_shouldRedactPersonalData() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "SHIPPED"); + + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + Optional result = service.getOrder(orderId); + + assertTrue(result.isPresent()); + OrderDto dto = result.get(); + assertNull(dto.getCustomerEmail()); + assertNull(dto.getCustomerPhone()); + assertNull(dto.getBillingAddress()); + assertNull(dto.getShippingAddress()); + } + + @Test + void getTwintQr_withOversizedInput_shouldClampSizeTo600() { + UUID orderId = UUID.randomUUID(); + Order order = buildOrder(orderId, "PENDING_PAYMENT"); + + byte[] png = new byte[]{1, 2, 3}; + when(orderRepo.findById(orderId)).thenReturn(Optional.of(order)); + when(twintPaymentService.generateQrPng(order, 600)).thenReturn(png); + + ResponseEntity response = service.getTwintQr(orderId, 5000); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType()); + assertArrayEquals(png, response.getBody()); + verify(twintPaymentService).generateQrPng(order, 600); + } + + private Order buildOrder(UUID orderId, String status) { + Order order = new Order(); + order.setId(orderId); + order.setStatus(status); + order.setCustomerEmail("customer@example.com"); + order.setCustomerPhone("+41910000000"); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Mario"); + order.setBillingLastName("Rossi"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setShippingSameAsBilling(true); + order.setCurrency("CHF"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSubtotalChf(BigDecimal.ZERO); + order.setCadTotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + return order; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java new file mode 100644 index 0000000..b14f688 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/ContactRequestLocalizationServiceTest.java @@ -0,0 +1,74 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ContactRequestLocalizationServiceTest { + + private ContactRequestLocalizationService service; + + @BeforeEach + void setUp() { + service = new ContactRequestLocalizationService(); + } + + @Test + void normalizeLanguage_shouldMapKnownPrefixes() { + assertEquals("de", service.normalizeLanguage("de-CH")); + assertEquals("en", service.normalizeLanguage("EN")); + assertEquals("fr", service.normalizeLanguage("fr_CA")); + assertEquals("it", service.normalizeLanguage("")); + } + + @Test + void resolveRecipientName_shouldUsePriorityAndFallback() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setName("Mario Rossi"); + assertEquals("Mario Rossi", service.resolveRecipientName(request, "it")); + + request.setName(" "); + request.setContactPerson("Laura Bianchi"); + assertEquals("Laura Bianchi", service.resolveRecipientName(request, "it")); + + request.setContactPerson(" "); + request.setCompanyName("3D Fab SA"); + assertEquals("3D Fab SA", service.resolveRecipientName(request, "it")); + + request.setCompanyName(" "); + assertEquals("customer", service.resolveRecipientName(request, "en")); + } + + @Test + void applyCustomerContactRequestTexts_shouldPopulateLocalizedLabels() { + Map templateData = new HashMap<>(); + templateData.put("recipientName", "Mario"); + UUID requestId = UUID.randomUUID(); + + String subject = service.applyCustomerContactRequestTexts(templateData, "fr", requestId); + + assertEquals("Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab", subject); + assertEquals("Date", templateData.get("labelDate")); + assertEquals("Bonjour Mario,", templateData.get("greetingText")); + } + + @Test + void localizeRequestType_andCustomerType_shouldReturnExpectedValues() { + assertEquals("Custom part request", service.localizeRequestType("print_service", "en")); + assertEquals("Azienda", service.localizeCustomerType("business", "it")); + assertEquals("-", service.localizeCustomerType(null, "de")); + } + + @Test + void localeForLanguage_shouldReturnExpectedLocale() { + assertEquals(Locale.GERMAN, service.localeForLanguage("de")); + assertEquals(Locale.ITALIAN, service.localeForLanguage("unknown")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java new file mode 100644 index 0000000..007f5f8 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestAttachmentServiceTest.java @@ -0,0 +1,163 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.service.storage.ClamAVService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestAttachmentServiceTest { + + @Mock + private CustomQuoteRequestAttachmentRepository attachmentRepo; + @Mock + private ClamAVService clamAVService; + + @InjectMocks + private CustomQuoteRequestAttachmentService service; + + private UUID lastRequestIdForCleanup; + + @AfterEach + void cleanStorageDirectory() { + if (lastRequestIdForCleanup == null) { + return; + } + Path requestDir = Paths.get("storage_requests", "quote-requests", lastRequestIdForCleanup.toString()); + if (!Files.exists(requestDir)) { + return; + } + try (var walk = Files.walk(requestDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + void storeAttachments_withNullFiles_shouldReturnZero() throws Exception { + CustomQuoteRequest request = buildRequest(); + + int count = service.storeAttachments(request, null); + + assertEquals(0, count); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withTooManyFiles_shouldThrowIOException() { + CustomQuoteRequest request = buildRequest(); + List files = new ArrayList<>(); + for (int i = 0; i < 16; i++) { + files.add(new MockMultipartFile("files", "file-" + i + ".stl", "model/stl", "solid".getBytes(StandardCharsets.UTF_8))); + } + + IOException ex = assertThrows( + IOException.class, + () -> service.storeAttachments(request, new ArrayList<>(files)) + ); + + assertTrue(ex.getMessage().contains("Too many files")); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withCompressedFile_shouldThrowBadRequest() { + CustomQuoteRequest request = buildRequest(); + MockMultipartFile file = new MockMultipartFile( + "files", + "archive.zip", + "application/zip", + "dummy".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.storeAttachments(request, List.of(file)) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(clamAVService, attachmentRepo); + } + + @Test + void storeAttachments_withValidFile_shouldScanPersistAndWriteOnDisk() throws Exception { + CustomQuoteRequest request = buildRequest(); + lastRequestIdForCleanup = request.getId(); + + MockMultipartFile file = new MockMultipartFile( + "files", + "part.stl", + "model/stl", + "solid model".getBytes(StandardCharsets.UTF_8) + ); + + when(clamAVService.scan(any())).thenReturn(true); + when(attachmentRepo.save(any(CustomQuoteRequestAttachment.class))).thenAnswer(invocation -> { + CustomQuoteRequestAttachment attachment = invocation.getArgument(0); + if (attachment.getId() == null) { + attachment.setId(UUID.randomUUID()); + } + return attachment; + }); + + int savedCount = service.storeAttachments(request, List.of(file)); + + assertEquals(1, savedCount); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CustomQuoteRequestAttachment.class); + verify(attachmentRepo, times(2)).save(captor.capture()); + verify(clamAVService, times(1)).scan(any()); + + CustomQuoteRequestAttachment persisted = captor.getAllValues().get(1); + Path absolutePath = Paths.get("storage_requests").toAbsolutePath().normalize() + .resolve(persisted.getStoredRelativePath()) + .normalize(); + + assertTrue(Files.exists(absolutePath)); + assertEquals("solid model", Files.readString(absolutePath, StandardCharsets.UTF_8)); + } + + private CustomQuoteRequest buildRequest() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(UUID.randomUUID()); + return request; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java new file mode 100644 index 0000000..e1a267a --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestControllerServiceTest.java @@ -0,0 +1,110 @@ +package com.printcalculator.service.request; + +import com.printcalculator.dto.QuoteRequestDto; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestControllerServiceTest { + + @Mock + private CustomQuoteRequestRepository requestRepo; + @Mock + private CustomQuoteRequestAttachmentService attachmentService; + @Mock + private CustomQuoteRequestNotificationService notificationService; + + @InjectMocks + private CustomQuoteRequestControllerService service; + + @Test + void createCustomQuoteRequest_withMissingConsents_shouldThrowBadRequest() throws Exception { + QuoteRequestDto dto = buildRequest(false, true); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createCustomQuoteRequest(dto, List.of()) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(requestRepo, attachmentService, notificationService); + } + + @Test + void createCustomQuoteRequest_withValidPayload_shouldPersistAndDelegate() throws Exception { + UUID requestId = UUID.randomUUID(); + QuoteRequestDto dto = buildRequest(true, true); + List files = List.of(); + + when(requestRepo.save(any(CustomQuoteRequest.class))).thenAnswer(invocation -> { + CustomQuoteRequest request = invocation.getArgument(0); + request.setId(requestId); + return request; + }); + when(attachmentService.storeAttachments(any(CustomQuoteRequest.class), eq(files))).thenReturn(2); + + CustomQuoteRequest saved = service.createCustomQuoteRequest(dto, files); + + assertNotNull(saved); + assertEquals(requestId, saved.getId()); + assertEquals("PENDING", saved.getStatus()); + assertNotNull(saved.getCreatedAt()); + assertNotNull(saved.getUpdatedAt()); + + verify(requestRepo).save(any(CustomQuoteRequest.class)); + verify(attachmentService).storeAttachments(saved, files); + verify(notificationService).sendNotifications(saved, 2, "de-CH"); + } + + @Test + void getCustomQuoteRequest_shouldDelegateToRepository() { + UUID requestId = UUID.randomUUID(); + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(requestId); + when(requestRepo.findById(requestId)).thenReturn(Optional.of(request)); + + Optional result = service.getCustomQuoteRequest(requestId); + + assertEquals(Optional.of(request), result); + verify(requestRepo).findById(requestId); + } + + private QuoteRequestDto buildRequest(boolean acceptTerms, boolean acceptPrivacy) { + QuoteRequestDto dto = new QuoteRequestDto(); + dto.setRequestType("PRINT_SERVICE"); + dto.setCustomerType("PRIVATE"); + dto.setLanguage("de-CH"); + dto.setEmail("customer@example.com"); + dto.setPhone("+41910000000"); + dto.setName("Mario Rossi"); + dto.setCompanyName("3D Fab SA"); + dto.setContactPerson("Mario Rossi"); + dto.setMessage("Vorrei una quotazione."); + dto.setAcceptTerms(acceptTerms); + dto.setAcceptPrivacy(acceptPrivacy); + return dto; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java new file mode 100644 index 0000000..121faee --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/request/CustomQuoteRequestNotificationServiceTest.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.request; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.service.email.EmailNotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CustomQuoteRequestNotificationServiceTest { + + @Mock + private EmailNotificationService emailNotificationService; + + private ContactRequestLocalizationService localizationService; + private CustomQuoteRequestNotificationService service; + + @BeforeEach + void setUp() { + localizationService = new ContactRequestLocalizationService(); + service = new CustomQuoteRequestNotificationService(emailNotificationService, localizationService); + } + + @Test + void sendNotifications_withAdminAndCustomerEnabled_shouldSendBothEmails() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", "admin@3d-fab.ch"); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", true); + + CustomQuoteRequest request = buildRequest(); + + service.sendNotifications(request, 3, "en-US"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> dataCaptor = (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Map.class); + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor templateCaptor = ArgumentCaptor.forClass(String.class); + + verify(emailNotificationService, times(2)).sendEmail( + toCaptor.capture(), + subjectCaptor.capture(), + templateCaptor.capture(), + dataCaptor.capture() + ); + + List recipients = toCaptor.getAllValues(); + assertTrue(recipients.contains("admin@3d-fab.ch")); + assertTrue(recipients.contains("customer@example.com")); + + int customerIndex = recipients.indexOf("customer@example.com"); + assertEquals("contact-request-customer", templateCaptor.getAllValues().get(customerIndex)); + assertEquals("We received your contact request #" + request.getId() + " - 3D-Fab", subjectCaptor.getAllValues().get(customerIndex)); + assertEquals("Date", dataCaptor.getAllValues().get(customerIndex).get("labelDate")); + + int adminIndex = recipients.indexOf("admin@3d-fab.ch"); + assertEquals("contact-request-admin", templateCaptor.getAllValues().get(adminIndex)); + assertEquals(3, dataCaptor.getAllValues().get(adminIndex).get("attachmentsCount")); + } + + @Test + void sendNotifications_withCustomerDisabled_shouldOnlySendAdminEmail() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", "admin@3d-fab.ch"); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", false); + + service.sendNotifications(buildRequest(), 1, "it"); + + verify(emailNotificationService, times(1)).sendEmail( + org.mockito.ArgumentMatchers.eq("admin@3d-fab.ch"), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("contact-request-admin"), + org.mockito.ArgumentMatchers.anyMap() + ); + } + + @Test + void sendNotifications_withMissingAdminAddressAndCustomerDisabled_shouldSendNothing() { + ReflectionTestUtils.setField(service, "contactRequestAdminMailEnabled", true); + ReflectionTestUtils.setField(service, "contactRequestAdminMailAddress", " "); + ReflectionTestUtils.setField(service, "contactRequestCustomerMailEnabled", false); + + service.sendNotifications(buildRequest(), 1, "fr"); + + verify(emailNotificationService, never()).sendEmail( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyMap() + ); + } + + private CustomQuoteRequest buildRequest() { + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setId(UUID.randomUUID()); + request.setRequestType("PRINT_SERVICE"); + request.setCustomerType("PRIVATE"); + request.setName("Mario Rossi"); + request.setCompanyName("3D Fab SA"); + request.setContactPerson("Mario Rossi"); + request.setEmail("customer@example.com"); + request.setPhone("+41910000000"); + request.setMessage("Vorrei una quotazione."); + request.setCreatedAt(OffsetDateTime.parse("2026-03-05T10:15:30+01:00")); + return request; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 521def9..335ecad 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -193,17 +193,22 @@

Qta: {{ item.quantity }} | Materiale: - {{ item.materialCode || "-" }} | Colore: + {{ getItemMaterialLabel(item) }} | Colore: - {{ item.colorCode || "-" }} + + {{ getItemColorLabel(item) }} + + ({{ colorCode }}) + + | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: {{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.infillPercent ?? "-" }}% | Supporti: - {{ item.supportsEnabled ? "Sì" : "No" }} + {{ formatSupports(item.supportsEnabled) }} | Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

@@ -283,10 +288,11 @@
{{ item.originalFilename }} - {{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm + {{ getItemMaterialLabel(item) }} | Colore: + {{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} | - {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }} + {{ formatSupportsState(item.supportsEnabled) }}
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 74dbd4c..836425e 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -3,6 +3,7 @@ import { Component, inject, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AdminOrder, + AdminOrderItem, AdminOrdersService, } from '../services/admin-orders.service'; import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive'; @@ -273,6 +274,68 @@ export class AdminDashboardComponent implements OnInit { ); } + getItemMaterialLabel(item: AdminOrderItem): string { + const variantName = (item.filamentVariantDisplayName || '').trim(); + const materialCode = (item.materialCode || '').trim(); + if (!variantName) { + return materialCode || '-'; + } + if (!materialCode) { + return variantName; + } + const normalizedVariant = variantName.toLowerCase(); + const normalizedCode = materialCode.toLowerCase(); + return normalizedVariant.includes(normalizedCode) + ? variantName + : `${variantName} (${materialCode})`; + } + + getItemColorLabel(item: AdminOrderItem): string { + const colorName = (item.filamentColorName || '').trim(); + const colorCode = (item.colorCode || '').trim(); + return colorName || colorCode || '-'; + } + + getItemColorHex(item: AdminOrderItem): string | null { + const variantHex = (item.filamentColorHex || '').trim(); + if (this.isHexColor(variantHex)) { + return variantHex; + } + const code = (item.colorCode || '').trim(); + if (this.isHexColor(code)) { + return code; + } + return null; + } + + getItemColorCodeSuffix(item: AdminOrderItem): string | null { + const colorHex = this.getItemColorHex(item); + if (!colorHex) { + return null; + } + return colorHex === this.getItemColorLabel(item) ? null : colorHex; + } + + formatSupports(value?: boolean): string { + if (value === true) { + return 'Sì'; + } + if (value === false) { + return 'No'; + } + return '-'; + } + + formatSupportsState(value?: boolean): string { + if (value === true) { + return 'Supporti ON'; + } + if (value === false) { + return 'Supporti OFF'; + } + return 'Supporti -'; + } + isSelected(orderId: string): boolean { return this.selectedOrder?.id === orderId; } diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 395010c..0ac32c4 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -8,6 +8,10 @@ export interface AdminOrderItem { originalFilename: string; materialCode: string; colorCode: string; + filamentVariantId?: number; + filamentVariantDisplayName?: string; + filamentColorName?: string; + filamentColorHex?: string; quality?: string; nozzleDiameterMm?: number; layerHeightMm?: number; diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 5c3b16f..ac81350 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -5,11 +5,11 @@
- {{ totals().price | currency: result().currency }} + {{ costBreakdown().subtotal | currency: result().currency }} @@ -22,18 +22,6 @@
- {{ - "CALC.SETUP_NOTE" - | translate - : { cost: (result().setupCost | currency: result().currency) } - }}
- @if ((result().cadTotal || 0) > 0) { - - Servizio CAD: {{ result().cadTotal | currency: result().currency }} - -
- } {{ "CALC.SHIPPING_NOTE" | translate }} @@ -63,7 +51,7 @@ {{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitWeight | number: "1.0-0" }}g | - {{ item.material || "N/D" }} + {{ item.material || "N/D" }} @if (getItemDifferenceLabel(item.fileName, item.material)) { | @@ -108,6 +96,25 @@ }
+
+
+ Costo di Avvio + {{ costBreakdown().baseSetup | currency: result().currency }} +
+ @if (costBreakdown().nozzleChange > 0) { +
+ Cambio Ugello + {{ + costBreakdown().nozzleChange | currency: result().currency + }} +
+ } +
+ Totale + {{ costBreakdown().total | currency: result().currency }} +
+
+
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 2ed738c..6ab0848 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -55,19 +55,6 @@ color: var(--color-text-muted); } -.material-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid #d9d4bd; - background: #fbf7e9; - color: #6d5b1d; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.2px; -} - .item-controls { display: flex; align-items: center; @@ -218,3 +205,35 @@ color: #6f5b1a; font-size: 0.9rem; } + +.cost-breakdown { + margin-top: var(--space-2); + margin-bottom: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); +} + +.cost-row, +.cost-total { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); +} + +.cost-row { + color: var(--color-text); + font-size: 0.95rem; + margin-bottom: var(--space-2); +} + +.cost-total { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 2px solid var(--color-border); + font-size: 1.2rem; + font-weight: 700; + color: var(--color-text); +} diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts index 5784b3a..e187cad 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TranslateModule } from '@ngx-translate/core'; import { QuoteResultComponent } from './quote-result.component'; import { QuoteResult } from '../../services/quote-estimator.service'; @@ -38,7 +39,11 @@ describe('QuoteResultComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [QuoteResultComponent, TranslateModule.forRoot()], + imports: [ + QuoteResultComponent, + TranslateModule.forRoot(), + HttpClientTestingModule, + ], }).compileComponents(); fixture = TestBed.createComponent(QuoteResultComponent); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 3e1c30c..939b5ad 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -134,17 +134,37 @@ export class QuoteResultComponent implements OnDestroy { this.items().some((item) => item.quantity > this.directOrderLimit), ); - totals = computed(() => { + costBreakdown = computed(() => { const currentItems = this.items(); - const setup = this.result().setupCost; const cad = this.result().cadTotal || 0; - let price = setup + cad; + let subtotal = cad; + currentItems.forEach((item) => { + subtotal += item.unitPrice * item.quantity; + }); + + const nozzleChange = Math.max(0, this.result().nozzleChangeCost || 0); + const baseSetupRaw = + this.result().baseSetupCost != null + ? this.result().baseSetupCost + : this.result().setupCost - nozzleChange; + const baseSetup = Math.max(0, baseSetupRaw || 0); + const total = subtotal + baseSetup + nozzleChange; + + return { + subtotal: Math.round(subtotal * 100) / 100, + baseSetup: Math.round(baseSetup * 100) / 100, + nozzleChange: Math.round(nozzleChange * 100) / 100, + total: Math.round(total * 100) / 100, + }; + }); + + totals = computed(() => { + const currentItems = this.items(); let time = 0; let weight = 0; currentItems.forEach((i) => { - price += i.unitPrice * i.quantity; time += i.unitTime * i.quantity; weight += i.unitWeight * i.quantity; }); @@ -153,7 +173,7 @@ export class QuoteResultComponent implements OnDestroy { const minutes = Math.ceil((time % 3600) / 60); return { - price: Math.round(price * 100) / 100, + price: this.costBreakdown().total, hours, minutes, weight: Math.ceil(weight), diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 25ab039..d2e9417 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -107,7 +107,27 @@ >.

- @if (mode() === "advanced") { + @if (mode() === "easy") { +
+ + + +
+ } @else {
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | @@ -328,7 +330,14 @@
{{ "CHECKOUT.SETUP_FEE" | translate }} - {{ session.session.setupCostChf | currency: "CHF" }} + {{ + (session.baseSetupCostChf ?? session.session.setupCostChf) + | currency: "CHF" + }} +
+
+ Cambio Ugello + {{ session.nozzleChangeCostChf | currency: "CHF" }}
{{ "CHECKOUT.SHIPPING" | translate }} diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index 081022b..d757b5f 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -230,12 +230,24 @@ app-toggle-selector.user-type-selector-compact { font-size: 0.85rem; color: var(--color-text-muted); + .item-color { + display: inline-flex; + align-items: center; + gap: 6px; + } + .color-dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; border: 1px solid var(--color-border); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + } + + .color-name { + font-weight: 500; + color: var(--color-text-muted); } } diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 66e98fc..72ab9a3 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -18,6 +18,7 @@ import { } from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; import { LanguageService } from '../../core/services/language.service'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; +import { getColorHex } from '../../core/constants/colors.const'; @Component({ selector: 'app-checkout', @@ -55,6 +56,8 @@ export class CheckoutComponent implements OnInit { selectedPreviewFile = signal(null); selectedPreviewName = signal(''); selectedPreviewColor = signal('#c9ced6'); + private variantHexById = new Map(); + private variantHexByColorName = new Map(); userTypeOptions: ToggleOption[] = [ { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, @@ -128,6 +131,8 @@ export class CheckoutComponent implements OnInit { } ngOnInit(): void { + this.loadMaterialColorPalette(); + this.route.queryParams.subscribe((params) => { this.sessionId = params['session']; if (!this.sessionId) { @@ -212,8 +217,40 @@ export class CheckoutComponent implements OnInit { } previewColor(item: any): string { + return this.itemColorSwatch(item); + } + + itemColorLabel(item: any): string { const raw = String(item?.colorCode ?? '').trim(); - return raw || '#c9ced6'; + return raw || '-'; + } + + itemColorSwatch(item: any): string { + const variantId = Number(item?.filamentVariantId); + if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) { + return this.variantHexById.get(variantId)!; + } + + const raw = String(item?.colorCode ?? '').trim(); + if (!raw) { + return '#c9ced6'; + } + + if (this.isHexColor(raw)) { + return raw; + } + + const byName = this.variantHexByColorName.get(raw.toLowerCase()); + if (byName) { + return byName; + } + + const fallback = getColorHex(raw); + if (fallback && fallback !== '#facf0a') { + return fallback; + } + + return '#c9ced6'; } isPreviewLoading(item: any): boolean { @@ -250,6 +287,41 @@ export class CheckoutComponent implements OnInit { this.selectedPreviewColor.set('#c9ced6'); } + private loadMaterialColorPalette(): void { + this.quoteService.getOptions().subscribe({ + next: (options) => { + this.variantHexById.clear(); + this.variantHexByColorName.clear(); + + for (const material of options?.materials || []) { + for (const variant of material?.variants || []) { + const variantId = Number(variant?.id); + const colorHex = String(variant?.hexColor || '').trim(); + const colorName = String(variant?.colorName || '').trim(); + + if (Number.isFinite(variantId) && colorHex) { + this.variantHexById.set(variantId, colorHex); + } + if (colorName && colorHex) { + this.variantHexByColorName.set(colorName.toLowerCase(), colorHex); + } + } + } + }, + error: () => { + this.variantHexById.clear(); + this.variantHexByColorName.clear(); + }, + }); + } + + private isHexColor(value?: string): boolean { + return ( + typeof value === 'string' && + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) + ); + } + private loadStlPreviews(session: any): void { if ( !this.sessionId || From 811e0f441b6e5707292b64fb5f83f4dd81fed9df Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Thu, 5 Mar 2026 17:31:15 +0000 Subject: [PATCH 10/10] style: apply prettier formatting --- .../calculator/services/quote-estimator.service.ts | 5 +++-- frontend/src/app/features/checkout/checkout.component.html | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 9ec6d61..988cc7b 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -384,8 +384,9 @@ export class QuoteEstimatorService { ); const grandTotal = Number(sessionData?.grandTotalChf); - const effectiveSetupCost = - Number(sessionData?.setupCostChf ?? session?.setupCostChf ?? 0); + const effectiveSetupCost = Number( + sessionData?.setupCostChf ?? session?.setupCostChf ?? 0, + ); const fallbackTotal = Number(sessionData?.itemsTotalChf || 0) + effectiveSetupCost + diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index f95bec6..5cda654 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -331,11 +331,14 @@
{{ "CHECKOUT.SETUP_FEE" | translate }} {{ - (session.baseSetupCostChf ?? session.session.setupCostChf) + session.baseSetupCostChf ?? session.session.setupCostChf | currency: "CHF" }}
-
+
Cambio Ugello {{ session.nozzleChangeCostChf | currency: "CHF" }}