fix(back-end): update profile inheritance
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Failing after 19s
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-05 17:12:18 +01:00
parent 73ccf8f4de
commit 5bc698815c
2 changed files with 230 additions and 128 deletions

View File

@@ -1,37 +1,74 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component'; import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult } from '../../services/quote-estimator.service'; import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({ @Component({
selector: 'app-quote-result', selector: 'app-quote-result',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent], imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
template: ` template: `
<app-card> <app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3> <h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<!-- Detailed Items List -->
<div class="items-list">
@for (item of items(); track item.fileName; let i = $index) {
<div class="item-row">
<div class="item-info">
<span class="file-name">{{ item.fileName }}</span>
<span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span>
</div>
<div class="item-controls">
<div class="qty-control">
<label>Qtà:</label>
<input
type="number"
min="1"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
class="qty-input">
</div>
<div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div>
</div>
</div>
}
</div>
<div class="divider"></div>
<!-- Summary Grid -->
<div class="result-grid"> <div class="result-grid">
<app-summary-card <app-summary-card
class="item full-width" class="item full-width"
[label]="'CALC.COST' | translate" [label]="'CALC.COST' | translate"
[large]="true" [large]="true"
[highlight]="true"> [highlight]="true">
{{ result().price | currency:result().currency }} {{ totals().price | currency:result().currency }}
</app-summary-card> </app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate"> <app-summary-card [label]="'CALC.TIME' | translate">
{{ result().printTimeHours }}h {{ result().printTimeMinutes }}m {{ totals().hours }}h {{ totals().minutes }}m
</app-summary-card> </app-summary-card>
<app-summary-card [label]="'CALC.MATERIAL' | translate"> <app-summary-card [label]="'CALC.MATERIAL' | translate">
{{ result().materialUsageGrams }}g {{ totals().weight }}g
</app-summary-card> </app-summary-card>
</div> </div>
<div class="setup-note">
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
</div>
<div class="actions"> <div class="actions">
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button> <app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button> <app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button>
@@ -40,18 +77,133 @@ import { QuoteResult } from '../../services/quote-estimator.service';
`, `,
styles: [` styles: [`
.title { margin-bottom: var(--space-6); text-align: center; } .title { margin-bottom: var(--space-6); text-align: center; }
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-4) 0;
}
.items-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.item-info {
display: flex;
flex-direction: column;
}
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
.item-controls {
display: flex;
align-items: center;
gap: var(--space-4);
}
.qty-control {
display: flex;
align-items: center;
gap: var(--space-2);
label { font-size: 0.8rem; color: var(--color-text-muted); }
}
.qty-input {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
&:focus { outline: none; border-color: var(--color-brand); }
}
.item-price {
font-weight: 600;
min-width: 60px;
text-align: right;
}
.result-grid { .result-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-4); gap: var(--space-4);
margin-bottom: var(--space-6); margin-bottom: var(--space-2);
} }
.full-width { grid-column: span 2; } .full-width { grid-column: span 2; }
.setup-note {
text-align: center;
margin-bottom: var(--space-6);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.actions { display: flex; flex-direction: column; gap: var(--space-3); } .actions { display: flex; flex-direction: column; gap: var(--space-3); }
`] `]
}) })
export class QuoteResultComponent { export class QuoteResultComponent {
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>(); consult = output<void>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
constructor() {
effect(() => {
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
this.items.set(this.result().items.map(i => ({...i})));
}, { allowSignalWrites: true });
}
updateQuantity(index: number, newQty: number | string) {
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (qty < 1 || isNaN(qty)) return;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: qty };
return updated;
});
}
totals = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach(i => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight)
};
});
} }

View File

@@ -5,10 +5,9 @@ import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
export interface QuoteRequest { export interface QuoteRequest {
files: File[]; items: { file: File, quantity: number }[];
material: string; material: string;
quality: string; quality: string;
quantity: number;
notes?: string; notes?: string;
color?: string; color?: string;
infillDensity?: number; infillDensity?: number;
@@ -17,13 +16,24 @@ export interface QuoteRequest {
mode: 'easy' | 'advanced'; mode: 'easy' | 'advanced';
} }
export interface QuoteItem {
fileName: string;
unitPrice: number;
unitTime: number; // seconds
unitWeight: number; // grams
quantity: number;
// Computed values for UI convenience (optional, can be done in component)
}
export interface QuoteResult { export interface QuoteResult {
price: number; items: QuoteItem[];
currency: string;
printTimeHours: number;
printTimeMinutes: number;
materialUsageGrams: number;
setupCost: number; setupCost: number;
currency: string;
// The following are aggregations that can be re-calculated
totalPrice: number;
totalTimeHours: number;
totalTimeMinutes: number;
totalWeight: number;
} }
interface BackendResponse { interface BackendResponse {
@@ -45,80 +55,17 @@ export class QuoteEstimatorService {
private http = inject(HttpClient); private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
const formData = new FormData(); if (request.items.length === 0) return of();
// Assuming single file primarily for now, or aggregating.
// The current UI seems to select one "active" file or handle multiple.
// The logic below was mapping multiple files to multiple requests.
// To support progress seamlessly for the "main" action, let's focus on the processing flow.
// If multiple files, we might need a more complex progress tracking or just track the first/total.
// Given the UI shows one big "Analyse" button, let's treat it as a batch or single.
// NOTE: The previous logic did `request.files.map(...)`.
// If we want a global progress, we can mistakenly complexity it.
// Let's assume we upload all files in one request if the API supported it, but the API seems to be 1 file per request from previous code?
// "formData.append('file', file)" inside the map implies multiple requests.
// To keep it simple and working with the progress bar which is global:
// We will emit progress for the *current* file being processed or average them.
// OR simpler: The user typically uploads one file for a quote?
// The UI `files: File[]` allows multiple.
// Let's stick to the previous logic but wrap it to emit progress.
// However, forkJoin waits for all. We can't easily get specialized progress for "overall upload" with forkJoin of distinct requests easily without merging.
// Refined approach:
// We will process files IN PARALLEL (forkJoin) but we can't easily track aggregated upload progress of multiple requests in a single simple number without extra code.
// BUT, the user wants "la barra di upload".
// If we assume standard use case is 1 file, it's easy.
// If multiple, we can emit progress as "average of all uploads" or just "uploading...".
// Let's modify the signature to return `Observable<{ type: 'progress' | 'result', value: any }>` or similar?
// The plan said `Observable<QuoteResult>` originally, now we need progress.
// Let's change return type to `Observable<any>` or a specific union.
// Let's handle just the first file for progress visualization simplicity if multiple are present,
// or better, create a wrapper that merges the progress.
// Actually, looking at the previous code: `const requests = request.files.map(...)`.
// If we have 3 files, we have 3 requests.
// We can emit progress events.
// START implementation for generalized progress:
const file = request.files[0]; // Primary target for now to ensure we have a progress to show.
// Ideally we should upload all.
// For this task, to satisfy "bar disappears after upload", we really need to know when upload finishes.
// Let's keep it robust:
// If multiple files, we likely want to just process them.
// Let's stick to the previous logic but capture progress events for at least one or all.
if (request.files.length === 0) return of();
// We will change the architecture slightly:
// We will execute requests and for EACH, we track progress.
// But we only have one boolean 'loading' and one 'progress' bar in UI.
// Let's average the progress?
// Simplification: The user probably uploads one file to check quote.
// Let's implement support for the first file's progress to drive the UI bar, handling the rest in background/parallel.
// Re-implementing the single file logic from the map, but enabled for progress.
return new Observable(observer => { return new Observable(observer => {
let completed = 0; const totalItems = request.items.length;
let total = request.files.length; const allProgress: number[] = new Array(totalItems).fill(0);
const results: BackendResponse[] = []; const finalResponses: any[] = [];
let grandTotal = 0; // For progress calculation if we wanted to average let completedRequests = 0;
// We'll just track the "upload phase" of the bundle. const uploads = request.items.map((item, index) => {
// Actually, let's just use `concat` or `merge`?
// Let's simplify: We will only track progress for the first file or "active" file.
// But the previous code sent ALL files.
// Let's change the return type to emit events.
const uploads = request.files.map(file => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', item.file);
formData.append('machine', 'bambu_a1'); formData.append('machine', 'bambu_a1');
formData.append('filament', this.mapMaterial(request.material)); formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality)); formData.append('quality', this.mapQuality(request.quality));
@@ -138,27 +85,19 @@ export class QuoteEstimatorService {
reportProgress: true, reportProgress: true,
observe: 'events' observe: 'events'
}).pipe( }).pipe(
map(event => ({ file, event })), map(event => ({ item, event, index })),
catchError(err => of({ file, error: err })) catchError(err => of({ item, error: err, index }))
); );
}); });
// We process all uploads.
// We want to emit:
// 1. Progress updates (average of all files?)
// 2. Final QuoteResult
const allProgress: number[] = new Array(request.files.length).fill(0);
let completedRequests = 0;
const finalResponses: any[] = [];
// Subscribe to all // Subscribe to all
uploads.forEach((obs, index) => { uploads.forEach((obs) => {
obs.subscribe({ obs.subscribe({
next: (wrapper: any) => { next: (wrapper: any) => {
const idx = wrapper.index;
if (wrapper.error) { if (wrapper.error) {
// handled in final calculation finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
return; return;
} }
@@ -166,64 +105,75 @@ export class QuoteEstimatorService {
if (event.type === 1) { // HttpEventType.UploadProgress if (event.type === 1) { // HttpEventType.UploadProgress
if (event.total) { if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total); const percent = Math.round((100 * event.loaded) / event.total);
allProgress[index] = percent; allProgress[idx] = percent;
// Emit average progress // Emit average progress
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total); const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg); // Emit number for progress observer.next(avg);
} }
} else if (event.type === 4) { // HttpEventType.Response } else if (event.type === 4) { // HttpEventType.Response
allProgress[index] = 100; allProgress[idx] = 100;
finalResponses[index] = event.body; finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
completedRequests++; completedRequests++;
if (completedRequests === total) { if (completedRequests === totalItems) {
// All done // All done
observer.next(100); // Ensure complete observer.next(100);
// Calculate Totals // Calculate Results
const valid = finalResponses.filter(r => r && r.success); const setupCost = 10;
if (valid.length === 0 && finalResponses.length > 0) {
const items: QuoteItem[] = [];
finalResponses.forEach(res => {
if (res && res.success) {
items.push({
fileName: res.fileName,
unitPrice: res.data.cost.total,
unitTime: res.data.print_time_seconds,
unitWeight: res.data.material_grams,
quantity: res.originalQty // Use the requested quantity
});
}
});
if (items.length === 0) {
observer.error('All calculations failed.'); observer.error('All calculations failed.');
return; return;
} }
let totalPrice = 0; // Initial Aggregation
let grandTotal = setupCost;
let totalTime = 0; let totalTime = 0;
let totalWeight = 0; let totalWeight = 0;
let setupCost = 10;
valid.forEach(res => { items.forEach(item => {
totalPrice += res.data.cost.total; grandTotal += item.unitPrice * item.quantity;
totalTime += res.data.print_time_seconds; totalTime += item.unitTime * item.quantity;
totalWeight += res.data.material_grams; totalWeight += item.unitWeight * item.quantity;
}); });
totalPrice = (totalPrice * request.quantity) + setupCost;
totalWeight = totalWeight * request.quantity;
totalTime = totalTime * request.quantity;
const totalHours = Math.floor(totalTime / 3600); const totalHours = Math.floor(totalTime / 3600);
const totalMinutes = Math.ceil((totalTime % 3600) / 60); const totalMinutes = Math.ceil((totalTime % 3600) / 60);
const result: QuoteResult = { const result: QuoteResult = {
price: Math.round(totalPrice * 100) / 100, items,
setupCost,
currency: 'CHF', currency: 'CHF',
printTimeHours: totalHours, totalPrice: Math.round(grandTotal * 100) / 100,
printTimeMinutes: totalMinutes, totalTimeHours: totalHours,
materialUsageGrams: Math.ceil(totalWeight), totalTimeMinutes: totalMinutes,
setupCost totalWeight: Math.ceil(totalWeight)
}; };
observer.next(result); // Emit final object observer.next(result);
observer.complete(); observer.complete();
} }
} }
}, },
error: (err) => { error: (err) => {
console.error('Error in request', err); console.error('Error in request', err);
finalResponses[index] = { success: false };
completedRequests++; completedRequests++;
if (completedRequests === total) { if (completedRequests === totalItems) {
observer.error('Requests failed'); observer.error('Requests failed');
} }
} }