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.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;

View File

@@ -1,11 +1,9 @@
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 QuoteRequest {
items: {
export interface QuoteRequestItem {
file: File;
quantity: number;
material?: string;
@@ -17,14 +15,10 @@ export interface QuoteRequest {
infillPattern?: string;
layerHeight?: number;
nozzleDiameter?: number;
material?: string;
quality?: string;
nozzleDiameter?: number;
layerHeight?: number;
infillDensity?: number;
infillPattern?: string;
supportEnabled?: boolean;
}[];
}
export interface QuoteRequest {
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<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
return this.http
.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
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> {
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<any> {
@@ -244,13 +175,10 @@ export class QuoteEstimatorService {
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
{
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
headers,
responseType: 'blob',
},
);
});
}
getOrderConfirmation(orderId: string): Observable<Blob> {
@@ -272,87 +200,68 @@ export class QuoteEstimatorService {
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
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<number | QuoteResult>((observer) => {
const headers: any = {};
this.http
.post<any>(`${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<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 }) {
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,
};
}
}