dev #29

Merged
JoeKung merged 30 commits from dev into main 2026-03-09 09:58:45 +01:00
2 changed files with 224 additions and 264 deletions
Showing only changes of commit 8e23bd97e6 - Show all commits

View File

@@ -4,14 +4,12 @@ import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto; import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order; import com.printcalculator.entity.*;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService; import com.printcalculator.service.payment.QrBillService;

View File

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