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,36 +1,73 @@
import { Component, input, output } from '@angular/core';
import { Component, input, output, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.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({
selector: 'app-quote-result',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
template: `
<app-card>
<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">
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true">
{{ result().price | currency:result().currency }}
{{ totals().price | currency:result().currency }}
</app-summary-card>
<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 [label]="'CALC.MATERIAL' | translate">
{{ result().materialUsageGrams }}g
{{ totals().weight }}g
</app-summary-card>
</div>
<div class="setup-note">
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
</div>
<div class="actions">
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
@@ -40,18 +77,133 @@ import { QuoteResult } from '../../services/quote-estimator.service';
`,
styles: [`
.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 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-bottom: var(--space-6);
margin-bottom: var(--space-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); }
`]
})
export class QuoteResultComponent {
result = input.required<QuoteResult>();
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';
export interface QuoteRequest {
files: File[];
items: { file: File, quantity: number }[];
material: string;
quality: string;
quantity: number;
notes?: string;
color?: string;
infillDensity?: number;
@@ -17,13 +16,24 @@ export interface QuoteRequest {
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 {
price: number;
currency: string;
printTimeHours: number;
printTimeMinutes: number;
materialUsageGrams: number;
items: QuoteItem[];
setupCost: number;
currency: string;
// The following are aggregations that can be re-calculated
totalPrice: number;
totalTimeHours: number;
totalTimeMinutes: number;
totalWeight: number;
}
interface BackendResponse {
@@ -45,80 +55,17 @@ export class QuoteEstimatorService {
private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
const formData = new FormData();
// 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.
if (request.items.length === 0) return of();
return new Observable(observer => {
let completed = 0;
let total = request.files.length;
const results: BackendResponse[] = [];
let grandTotal = 0; // For progress calculation if we wanted to average
// We'll just track the "upload phase" of the bundle.
// 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 totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', file);
formData.append('file', item.file);
formData.append('machine', 'bambu_a1');
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
@@ -138,27 +85,19 @@ export class QuoteEstimatorService {
reportProgress: true,
observe: 'events'
}).pipe(
map(event => ({ file, event })),
catchError(err => of({ file, error: err }))
map(event => ({ item, event, index })),
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
uploads.forEach((obs, index) => {
uploads.forEach((obs) => {
obs.subscribe({
next: (wrapper: any) => {
const idx = wrapper.index;
if (wrapper.error) {
// handled in final calculation
finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
return;
}
@@ -166,64 +105,75 @@ export class QuoteEstimatorService {
if (event.type === 1) { // HttpEventType.UploadProgress
if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total);
allProgress[index] = percent;
allProgress[idx] = percent;
// Emit average progress
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total);
observer.next(avg); // Emit number for progress
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
}
} else if (event.type === 4) { // HttpEventType.Response
allProgress[index] = 100;
finalResponses[index] = event.body;
allProgress[idx] = 100;
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
completedRequests++;
if (completedRequests === total) {
if (completedRequests === totalItems) {
// All done
observer.next(100); // Ensure complete
observer.next(100);
// Calculate Totals
const valid = finalResponses.filter(r => r && r.success);
if (valid.length === 0 && finalResponses.length > 0) {
// Calculate Results
const setupCost = 10;
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.');
return;
}
let totalPrice = 0;
// Initial Aggregation
let grandTotal = setupCost;
let totalTime = 0;
let totalWeight = 0;
let setupCost = 10;
valid.forEach(res => {
totalPrice += res.data.cost.total;
totalTime += res.data.print_time_seconds;
totalWeight += res.data.material_grams;
items.forEach(item => {
grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity;
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 totalMinutes = Math.ceil((totalTime % 3600) / 60);
const result: QuoteResult = {
price: Math.round(totalPrice * 100) / 100,
items,
setupCost,
currency: 'CHF',
printTimeHours: totalHours,
printTimeMinutes: totalMinutes,
materialUsageGrams: Math.ceil(totalWeight),
setupCost
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: totalHours,
totalTimeMinutes: totalMinutes,
totalWeight: Math.ceil(totalWeight)
};
observer.next(result); // Emit final object
observer.next(result);
observer.complete();
}
}
},
error: (err) => {
console.error('Error in request', err);
finalResponses[index] = { success: false };
completedRequests++;
if (completedRequests === total) {
if (completedRequests === totalItems) {
observer.error('Requests failed');
}
}