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, }; } }